feat(forms): edit checkbox and radio export values (#231)

This commit is contained in:
FredApps
2026-10-13 19:18:41 +02:00
parent 7437bde378
commit fc14719dff
14 changed files with 246 additions and 10 deletions
+2
View File
@@ -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)
+12
View File
@@ -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`.
+16 -1
View File
@@ -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::<Vec<_>>()));
}
match f.kind {
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
@@ -327,6 +330,9 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
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")?,
+3 -1
View File
@@ -364,7 +364,7 @@ pub fn tools() -> Vec<ToolDef> {
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<ToolDef> {
"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" },
+23
View File
@@ -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();
}
+13
View File
@@ -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
+46
View File
@@ -68,6 +68,9 @@ pub struct FieldProps {
pub max_len: Option<Option<usize>>,
/// Choice options (display texts; export values equal them).
pub options: Option<Vec<String>>,
/// 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<f64>,
/// 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<Vec<Object>, 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<String, FormError> {
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<S
}
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?;
if props.export_value.is_some() && props.options.is_some() {
return invalid("give export_value or options, not both");
}
// Validated before anything is written, so a refused rotation leaves the document unchanged.
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
let mut rotated_rect: Option<Option<[f64; 4]>> = None;
@@ -879,6 +916,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
return invalid(format!("{name} is locked: unlock it first"));
}
if let Some(options) = export_options.as_ref()
&& *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() })
{
doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?;
return Ok(name.to_string());
}
if let Some(lock) = props.locked {
for w in &f.widgets {
let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0);
@@ -972,6 +1015,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
}
}
if let Some(options) = &export_options {
d.set(b"Opt".to_vec(), Object::Array(options.clone()));
}
if let Some(opts) = &props.options {
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
}
+9
View File
@@ -175,6 +175,15 @@ impl Field {
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
}
/// Values for data export, resolving button appearance names through /Opt.
pub fn export_values(&self) -> Vec<String> {
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 {
+39
View File
@@ -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::<Vec<_>>());
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);
}
}
+7 -1
View File
@@ -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") };
+33
View File
@@ -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()));
}
}
+8 -5
View File
@@ -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<Vec<(String, Vec<String>)>, 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());
+33
View File
@@ -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"]);
}
}
+2 -2
View File
@@ -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"