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] 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"