From fc14719dff434e692123cb19b8c94e07a2d314cc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:18:41 +0200 Subject: [PATCH 001/224] feat(forms): edit checkbox and radio export values (#231) --- ROADMAP.md | 2 ++ crates/automation/README.md | 12 ++++++++ crates/automation/src/forms.rs | 17 +++++++++- crates/automation/src/tools.rs | 4 ++- crates/automation/tests/form_bulk.rs | 23 ++++++++++++++ crates/forms/README.md | 13 ++++++++ crates/forms/src/author.rs | 46 ++++++++++++++++++++++++++++ crates/forms/src/lib.rs | 9 ++++++ crates/forms/src/tests.rs | 39 +++++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 8 ++++- crates/ui-egui/tests/prepare.rs | 33 ++++++++++++++++++++ crates/xfdf/src/lib.rs | 13 +++++--- crates/xfdf/src/tests.rs | 33 ++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 14 files changed, 246 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf35..3013de8b1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6 #231: edit checkbox/radio export values through individual field properties and automation, preserving selection and appearance; headless regression coverage, desktop visual verification pending; estimates unchanged) + **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) diff --git a/crates/automation/README.md b/crates/automation/README.md index f2e4ec9282..f20ad35ea1 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -184,3 +184,15 @@ unapplied with a warning. `doc_batch` runs conversion, OCR and compression over files and optionally recursive folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. + +## Editing button export values + +Use `form_set_props` with `field`, `export_value` and optional `widget` +(1-based, default 1) to change a checkbox or radio button export value. +`form_fields` returns `export_values` in widget order. The selected/default +appearance states are preserved; the operation is undoable and survives saving. +Widgets sharing an appearance state keep one export value. Bulk edits are not +supported for this widget-specific property. Empty values, `Off`, conflicting +choices and values over 4096 UTF-8 bytes return an error. + +Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 7c60425a8c..6089d5a36a 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -232,6 +232,9 @@ impl Automation { if !f.actions.unsupported.is_empty() { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } + if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) { + o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>())); + } match f.kind { FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); @@ -327,6 +330,9 @@ impl Automation { } pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { + if a.get("widget").is_some() && a.get("export_value").is_none() { + return Err(bad("widget requires export_value")); + } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); if single.is_some() == bulk { @@ -344,7 +350,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation"] { + for key in ["name", "rect", "rotation", "export_value", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -396,6 +402,15 @@ impl Automation { }; Ok(FieldProps { rect, + export_value: a + .opt_str("export_value")? + .map(|value| -> Result<_> { + let index = a.opt_int("widget")?.unwrap_or(1); + let index = + usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((index, value.to_owned())) + }) + .transpose()?, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), read_only: a.opt_bool("read_only")?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..be85f00077 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -364,7 +364,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and export_value require a single field. export_value edits a checkbox or radio widget (widget: 1-based, default 1), preserving its selected/default state; widgets sharing that state keep the same export value. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with({ let mut input = schema( @@ -373,6 +373,8 @@ pub fn tools() -> Vec { "field": { "type": "string", "description": "The field's current name." }, "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, + "export_value": { "type": "string", "minLength": 1, "description": "Checkbox/radio export value; not Off; at most 4096 UTF-8 bytes." }, + "widget": { "type": "integer", "minimum": 1, "description": "1-based widget for export_value (default 1)." }, "tooltip": { "type": "string" }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/form_bulk.rs index 8b8904c974..4296cbc891 100644 --- a/crates/automation/tests/form_bulk.rs +++ b/crates/automation/tests/form_bulk.rs @@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() { assert_eq!(bytes(&a, id), before); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn button_export_edit_is_one_undo_step_and_survives_save() { + let (mut a, id, dir) = setup("button-export"); + tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]})); + tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}})); + let before = bytes(&a, id); + call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1})); + let f = tool(&mut a, "form_fields", json!({"doc": id})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(bytes(&a, id), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + let f = tool(&mut a, "form_fields", json!({"doc": reopened})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 3280cc2431..bc8e19fc95 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -57,6 +57,19 @@ delete_field(&mut doc, "email")?; buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +## Button export values + +`FieldProps::export_value = Some((widget_index, value))` edits a checkbox or radio +button's export value. The API index is zero-based. It writes a complete `/Opt` +mapping, preserving appearance names, selected states and reset defaults; an +export-only change leaves custom appearance streams intact. Widgets sharing an +appearance state keep the same export value. Conflicting shared-state mappings, +ambiguous choices, invalid widget indices, empty values, `Off`, and values over +4096 UTF-8 bytes are refused. + +`Field::export_values` resolves button values for data exchange. An unchecked +button still exports as `Off` in the data formats. + ## Not yet Rich-text values and exact appearance metrics/layout remain incomplete. JavaScript diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 6d49be1001..a8c99cb483 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -68,6 +68,9 @@ pub struct FieldProps { pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, + /// Change one button widget's export value without changing its appearance state. + /// Widgets sharing that state are kept in sync. The index is zero-based. + pub export_value: Option<(usize, String)>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in @@ -845,6 +848,36 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) } +/// Build a complete /Opt mapping before modifying the document. Appearance names, +/// /V and /DV stay untouched, so an export-value edit does not change selection. +fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> { + if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have an export value"); + } + if value.is_empty() || value == "Off" || value.len() > 4096 { + return invalid("export value must be non-empty, at most 4096 bytes, and not Off"); + } + let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?; + let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + if !f.options.is_empty() && f.options.len() != f.widgets.len() { + return invalid("button export options do not match its widgets"); + } + let previous = f.export_of(index); + let mut out = Vec::with_capacity(f.widgets.len()); + for (i, w) in f.widgets.iter().enumerate() { + let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + let same_state = w.on_state.as_deref() == Some(state); + if !same_state && (export == value || w.on_state.as_deref() == Some(value)) { + return invalid("export value conflicts with another button choice"); + } + if same_state && Some(export) != previous { + return invalid("widgets with the same appearance state have conflicting export values"); + } + out.push(Object::String(PdfString::text(if same_state { value } else { export }))); + } + Ok(out) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -858,6 +891,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; @@ -879,6 +916,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Vec { + if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) { + self.value.iter().map(|v| self.export_for_state(v).to_string()).collect() + } else { + self.value.clone() + } + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0256bfafb9..e3fa748651 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1943,3 +1943,42 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); assert!(filled.rules.is_empty()); } + +#[test] +fn editing_button_export_values_preserves_selection_and_appearance() { + let mut doc = fixture(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap(); + let before = field(&fields(&doc), "size").clone(); + let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect(); + set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap(); + let after = field(&fields(&doc), "size").clone(); + assert_eq!(after.value, before.value); + assert_eq!(after.default, before.default); + assert_eq!(after.widgets, before.widgets); + assert_eq!(after.export_of(0), Some("Small å")); + assert_eq!(after.export_of(1), before.export_of(1)); + assert_eq!(after.export_values(), vec!["Small å"]); + assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>()); + let mut saved = reopen(&doc); + assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å")); + set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap(); + reset(&mut saved, None).unwrap(); + assert_eq!(field(&fields(&saved), "size").value, before.default); + + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + let check = field(&fields(&doc), "agree").clone(); + assert_eq!(check.value, vec!["Yes"]); + assert_eq!(check.export_values(), vec!["Accepted"]); + assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted")); +} + +#[test] +fn invalid_button_export_edits_leave_document_unchanged() { + let mut doc = fixture(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] { + assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 37747f14c7..762d090254 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -758,6 +758,7 @@ pub struct FieldDraft { pub default: String, /// Check boxes and radio buttons: this widget's "on" state (export value). pub on_state: String, + pub export_value: String, /// 0 = auto. pub font_size: f64, /// Left, bottom, width, height in points. @@ -824,6 +825,7 @@ impl FieldDraft { quadding: f.quadding, default: f.default.first().cloned().unwrap_or_default(), on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(), + export_value: f.export_of(widget).unwrap_or_default().to_string(), font_size: da_size(&f.da), position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0), @@ -877,6 +879,7 @@ impl FieldDraft { multiline: ch(self.multiline, o.multiline), max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)), options: (self.options != o.options).then(|| self.options.clone()), + export_value: (self.export_value != o.export_value).then(|| (self.widget, self.export_value.clone())), font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size), rect: (self.position != o.position).then(|| { let [x, y, w, h] = self.position; @@ -1123,7 +1126,10 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FormFieldKind::CheckBox | FormFieldKind::Radio => { use pdfcraft_engine::field_flags as ff; let on = d.on_state.clone(); - ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)])); + ui.horizontal(|ui| { + let label = ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", "")])); + ui.text_edit_singleline(&mut d.export_value).labelled_by(label.id); + }); let mut checked = !d.default.is_empty() && d.default == on; let label = if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index b23f1a9f8f..81359d8bc2 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -497,3 +497,36 @@ fn actions_tab_adds_and_removes_actions() { ] ); } + +#[test] +fn button_export_properties_apply_cancel_and_undo() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + let original = h.state().field_props.as_ref().unwrap().export_value.clone(); + h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("Cancel").click(); + h.run_steps(2); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original); + h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("OK").click(); + h.run_steps(3); + { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap(); + assert_eq!(f.export_of(0), Some("Accepted")); + } + h.state_mut().execute("edit.undo"); + h.run_steps(3); + { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap(); + assert_eq!(f.export_of(0), Some(original.as_str())); + } +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d9287101ba..9b1b439caf 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -420,7 +420,7 @@ fn xfdf_fields(doc: &Document, out: &mut String) { } node.values = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], - _ => f.value.clone(), + _ => f.export_values(), }; } fn write_node(name: &str, node: &Node, out: &mut String) { @@ -480,7 +480,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b" /V "); match f.kind { FieldKind::CheckBox | FieldKind::Radio => { - let v = f.value.first().cloned().unwrap_or_else(|| "Off".into()); + let v = f.export_values().first().cloned().unwrap_or_else(|| "Off".into()); pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); } FieldKind::List if f.value.len() > 1 => { @@ -575,7 +575,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { .map(|f| { let v = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => "Off".to_string(), - _ => f.value.join(", "), + _ => f.export_values().join(", "), }; (f.name, v) }) @@ -984,8 +984,11 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError .into_iter() .filter(|f| !matches!(f.kind, FieldKind::PushButton | FieldKind::Signature)) .map(|f| { - let v = - if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { vec!["Off".to_string()] } else { f.value }; + let v = if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { + vec!["Off".to_string()] + } else { + f.export_values() + }; (f.name, v) }) .collect()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index be747d77e1..d61273f1b5 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -887,3 +887,36 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}"); } } + +#[test] +fn edited_button_export_values_round_trip_in_all_data_formats() { + use pdfcraft_forms::{FieldProps, set_props}; + let mut doc = blank(); + add_field(&mut doc, 0, [80.0, 650.0, 94.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "S".into() }, None).unwrap(); + add_field(&mut doc, 0, [100.0, 650.0, 114.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "L".into() }, None).unwrap(); + set_props(&mut doc, "Agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + set_props(&mut doc, "Size", &FieldProps { export_value: Some((0, "Small".into())), ..Default::default() }).unwrap(); + set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); + set_value(&mut doc, "Size", &FieldValue::Radio(Some("Small".into()))).unwrap(); + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "")]; + exports.extend([Format::Xml, Format::Csv, Format::Txt].map(|f| export_data(&doc, f).into_bytes())); + for (index, bytes) in exports.into_iter().enumerate() { + if index < 2 { + let values = data_values(&bytes).unwrap(); + assert_eq!(values.iter().find(|(n, _)| n == "Agree").unwrap().1, vec!["Accepted"]); + assert_eq!(values.iter().find(|(n, _)| n == "Size").unwrap().1, vec!["Small"]); + } else { + // data_values reads XFDF/FDF/PDF; import also handles XML/CSV/TXT. + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("Accepted") && text.contains("Small"), "{text}"); + } + let mut target = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + set_value(&mut target, "Agree", &FieldValue::Check(false)).unwrap(); + set_value(&mut target, "Size", &FieldValue::Radio(Some("L".into()))).unwrap(); + import(&mut target, &bytes).unwrap(); + let fs = fields(&target); + assert_eq!(fs.iter().find(|f| f.name == "Agree").unwrap().export_values(), vec!["Accepted"]); + assert_eq!(fs.iter().find(|f| f.name == "Size").unwrap().export_values(), vec!["Small"]); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..f5189bc33e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4896,8 +4896,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] -notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::editing_button_export_values_preserves_selection_and_appearance", "crates/forms/src/tests.rs::invalid_button_export_edits_leave_document_unchanged", "crates/xfdf/src/tests.rs::edited_button_export_values_round_trip_in_all_data_formats", "crates/automation/tests/form_bulk.rs::button_export_edit_is_one_undo_step_and_survives_save", "crates/ui-egui/tests/prepare.rs::button_export_properties_apply_cancel_and_undo"] +notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison, individual widget export value via /Opt without changing selection or appearance; XFDF/FDF/XML/CSV/TXT exports use the mapped values. Export editing has headless Apply/Cancel/Undo and save/reopen coverage; desktop typing/appearance has not been visually verified. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" From 0e8d4d70fabbdc4f030af86f801604d1db5766a6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:31:14 +0200 Subject: [PATCH 002/224] test(forms): type export values through the properties widget --- crates/ui-egui/tests/prepare.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 81359d8bc2..896eeff196 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -500,6 +500,14 @@ fn actions_tab_adds_and_removes_actions() { #[test] fn button_export_properties_apply_cancel_and_undo() { + fn retype(h: &mut Harness<'static, PdfCraftApp>, value: &str) { + use egui::accesskit::Role; + h.get_by_role_and_label(Role::TextInput, "Export Value: ").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role_and_label(Role::TextInput, "Export Value: ").type_text(value); + h.run_steps(2); + } let mut h = harness(); h.state_mut().execute("form.prepare"); h.run_steps(2); @@ -508,13 +516,15 @@ fn button_export_properties_apply_cancel_and_undo() { h.get_by_label("Options").click(); h.run_steps(2); let original = h.state().field_props.as_ref().unwrap().export_value.clone(); - h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + retype(&mut h, "Accepted"); h.get_by_label("Cancel").click(); h.run_steps(2); h.state_mut().open_field_props("agree", 0); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original); - h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("Options").click(); + h.run_steps(2); + retype(&mut h, "Accepted"); h.get_by_label("OK").click(); h.run_steps(3); { From e2be8db7fbde73c0d10ba896b5dc19561f659bb8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:33:19 +0200 Subject: [PATCH 003/224] test(ui): stabilize Reduce metadata and isolate output directories --- crates/ui-egui/tests/create.rs | 35 ++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index bfbba2e131..4537ab1fa6 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -69,12 +69,37 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); } +// Use a directory claimed exclusively by this run, including after process-ID reuse. +struct ReduceDir(std::path::PathBuf); +impl ReduceDir { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for _ in 0..128 { + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("pdfcraft-reduce-{}-{n}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating Reduce test directory: {error}"), + } + } + panic!("no unused Reduce test directory after 128 attempts"); + } +} +impl Drop for ReduceDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// Open `name` and run Reduce File Size on it, saving to `out` without a dialog. fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> { let out2 = out.to_path_buf(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + // Keep creation and optimization metadata identical across wall-clock seconds. + app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000); app.open_bytes(name, None, bytes).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app @@ -105,9 +130,8 @@ fn reduce_file_size_writes_a_compact_copy() { enc.set_depth(png::BitDepth::Eight); enc.write_header().unwrap().write_image_data(&px).unwrap(); } - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); let h = reduce("photo.png", photo, &out); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); @@ -119,9 +143,8 @@ fn reduce_file_size_writes_a_compact_copy() { #[test] fn reduce_file_size_saves_nothing_when_the_file_is_already_small() { use egui_kittest::kittest::Queryable; - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); // A new text PDF is already compact: a copy would be no smaller (#490). let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out); assert!(!out.exists(), "no copy was written"); From 4e952e62e23e0381c68c2b0f616f4698d5f0f646 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:40:22 +0200 Subject: [PATCH 004/224] chore: reserve Pages sidebar select-all fix (#2052) From 971f6ea2b458a4627aab8b4dcf45ba42f00ef18a Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:14:35 +0200 Subject: [PATCH 005/224] Fix keyboard-owned Pages sidebar select all (#2052) --- ROADMAP.md | 5 +++- crates/ui-egui/src/canvas.rs | 15 ++++++++--- crates/ui-egui/src/lib.rs | 8 ++++-- crates/ui-egui/src/panels.rs | 15 ++++++++--- crates/ui-egui/tests/view.rs | 52 ++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 ++-- parity/acrobat-features.toml | 4 +-- 7 files changed, 89 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b4ab4ea8a6..593387f85a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M4/#2052 candidate: keyboard-owned Pages sidebar select-all; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -149,6 +149,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-14 (M4/#2052 candidate):** Ctrl/Cmd+A selects all thumbnails while the Pages sidebar owns the keyboard, preserves the current page, and keeps viewer/text-field shortcuts and document selection separate. Escape and existing permission checks are retained. Focus regression and scoped UI validation pass; estimates unchanged. + - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. @@ -427,6 +429,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | M4/#2052 candidate: Pages sidebar select-all, focus ownership, Escape and document isolation validated; estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..1a7bc20a27 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -433,6 +433,8 @@ pub struct DocView { pub org_drag: Option>, /// Pages panel: the pages being dragged to a new place. pub panel_drag: Option>, + /// Transient keyboard owner for this document's Pages sidebar. + pub(crate) pages_focus_id: Option, /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. pub marquee: Option<(usize, Pos2)>, pub marquee_done: Option, @@ -672,6 +674,7 @@ impl DocView { sign: Default::default(), org_drag: None, panel_drag: None, + pages_focus_id: None, marquee: None, marquee_done: None, fill_text: None, @@ -1137,10 +1140,7 @@ impl DocView { /// Select all: every page in Organize, or every word on the current page. pub fn select_all(&mut self) -> bool { if self.organize { - self.selected = (0..self.page_count).collect(); - // Keep the current page as the anchor for the next Shift-click. - self.select_anchor = (self.page_count > 0).then_some(self.current); - return !self.selected.is_empty(); + return self.select_all_pages(); } let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; @@ -1151,6 +1151,13 @@ impl DocView { true } + /// Select every page without navigating; shared by Organize and the Pages sidebar. + pub fn select_all_pages(&mut self) -> bool { + self.selected = (0..self.page_count).collect(); + self.select_anchor = (self.page_count > 0).then_some(self.current); + !self.selected.is_empty() + } + /// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−). pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a00..cad36ad3e1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2650,12 +2650,16 @@ impl PdfCraftApp { if let Some(i) = self.active { // Select all belongs to the document or page grid, unless a text field or // overlay owns the keyboard. Other canvas shortcuts keep their own handling. + let pages_owns_keyboard = + self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); if self.dialog.is_none() && !self.palette_open - && !ctx.egui_wants_keyboard_input() + && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) { - if self.is_preparing() { + if pages_owns_keyboard { + self.views[i].select_all_pages(); + } else if self.is_preparing() { if let Some(doc) = self.session.get(self.views[i].id) { prepare::select_all(&mut self.views[i], &doc.form); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..01f5b74fac 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1003,11 +1003,17 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte /// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging /// moves the selection, or the page grabbed, to the gap it is dropped on. fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option