M6: a check box's kids with different on states tick one at a time (ISO 32000-2 §12.7.5.2.3, Table 230)

A check box field with kids whose on states are A and B could only ever
tick A. With A ticked, clicking B cleared the field, so B's tick flashed
and vanished, and with nothing ticked, clicking B ticked A. The click sent a
field-wide Check(!on), and write_value always turned on the first kid's on
state, even for a value naming B.

§12.7.5.2.3 makes the field's /V the on state of the kid that is on (/AS
deciding where they differ), and Table 230 lets kids be checked
independently.

- A click on a kid that is off sends its on-state name: it (and any kid
  sharing that state) turns on and the others off. A click on a kid that is
  on clears the field. "On" is the kid's /AS (Field::widget_on).
- write_value: a named state or /Opt export ticks the kids with that state.
  Check(true) keeps the kid that is on, else takes the first.
- Field::check_box_value maps a stored value to the state it names. Reset to
  /DV, script value changes and FDF/XFDF import use it.
- The display-only fallback ticks only the kids that are on.
- form_fields lists a check box's options and the checked one when its kids
  have more than one on state. form_fill takes the name.

Kids sharing an on state still tick together (EXAMPLE 2).

Tests: forms unit, ui-egui end-to-end clicks, automation form_fill and
xfdf import. Each fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-31 13:59:31 +08:00
1 parent f137a289ba
commit 880ccd4c82
12 files changed
+278 -24

No files matched your search

+10
View File
@@ -295,6 +295,16 @@ impl Automation {
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
// A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3):
// which one is ticked, and the names form_fill takes to tick each.
FormFieldKind::CheckBox => {
let mut seen = std::collections::HashSet::new();
let options: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).filter(|e| seen.insert(*e)).collect();
if options.len() > 1 {
o.insert("options".into(), json!(options));
o.insert("checked".into(), json!(f.value.first().map(|v| f.export_for_state(v))));
}
}
FormFieldKind::Combo | FormFieldKind::List => {
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT)));
+2 -2
View File
@@ -391,13 +391,13 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). A check box whose widgets have different on states also lists them as options, and which one is checked (null when none).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
+27
View File
@@ -2630,6 +2630,33 @@ fn form_fill_warns_when_a_value_does_not_fit() {
assert!(r.get("warnings").is_none(), "{r}");
}
/// `form_fill` ticks one kid of a check box whose kids have different on states by naming it
/// (ISO 32000-2 §12.7.5.2.3, Table 230), and `form_fields` lists the names and the ticked one.
#[test]
fn form_fill_ticks_one_kid_of_a_check_box_by_its_on_state() {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
"<< /FT /Btn /T (pair) /V /Off /Kids [6 0 R 7 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 26 266] /P 3 0 R /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 56 266] /P 3 0 R /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let dir = workdir("check-box-kids");
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
let before = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!((&before["fields"][0]["options"], &before["fields"][0]["checked"]), (&json!(["A", "B"]), &Value::Null), "{before}");
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": "A" } }));
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": "B" } }));
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!((&after["fields"][0]["value"], &after["fields"][0]["checked"]), (&json!(true), &json!("B")), "{after}");
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": false } }));
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["checked"], Value::Null);
}
/// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and
/// resets through the tools, and the leniency shows up as a repair note.
#[test]
+24
View File
@@ -229,6 +229,30 @@ impl Field {
}
}
/// Whether widget `i` of a check box or radio group is on: its `/AS` is its on state. Widgets
/// sharing an on state tell themselves apart only by `/AS`, which ISO 32000-2 §12.7.5.2.3 and
/// §12.7.5.2.4 make the deciding entry where it and `/V` differ. A widget without `/AS`
/// follows the field's value.
pub fn widget_on(&self, i: usize) -> bool {
let Some(w) = self.widgets.get(i) else { return false };
let Some(on) = w.on_state.as_deref() else { return false };
match w.state.as_deref() {
Some(s) => s == on,
None => self.value.first().map(String::as_str) == Some(on),
}
}
/// The value that gives this check box the stored value `value` (`/V`, `/DV`, imported or
/// script data): the on state it names (or whose `/Opt` export it is), so that of kids with
/// different on states the right one turns on (ISO 32000-2 §12.7.5.2.3, Table 230); checked
/// for any other value but Off, unchecked for none.
pub fn check_box_value(&self, value: Option<&str>) -> FieldValue {
match value.filter(|v| !v.is_empty() && *v != "Off") {
Some(v) => self.state_for(v).map_or(FieldValue::Check(true), |s| FieldValue::Text(s.to_owned())),
None => FieldValue::Check(false),
}
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
+1 -1
View File
@@ -114,7 +114,7 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
{
let value = match f.kind {
FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()),
FieldKind::CheckBox => FieldValue::Check(!v.is_empty()),
FieldKind::CheckBox => f.check_box_value(v.first().map(String::as_str)),
FieldKind::Radio => FieldValue::Radio(v.first().cloned()),
FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()),
_ => continue,
+66
View File
@@ -5485,3 +5485,69 @@ fn locked_button_export_edits_leave_the_document_unchanged() {
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
}
/// Two check box fields of two kids each: `pair` with the on states `A` and `B` (and `/DV /B`),
/// `same` with `Yes` twice.
fn check_box_kids() -> Document {
let kid = |parent: usize, x: u32, state: &str| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} 700 {} 715] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 11 0 R >> >> >>",
x + 15
)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), // 3
"<< /Fields [5 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /FT /Btn /T (pair) /V /Off /DV /B /Kids [6 0 R 7 0 R] >>".into(), // 5
kid(5, 100, "A"), // 6
kid(5, 200, "B"), // 7
kid(12, 100, "Yes"), // 8
kid(12, 200, "Yes"), // 9
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
"<< /Length 0 >>\nstream\n\nendstream".into(), // 11
"<< /FT /Btn /T (same) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 12
];
document(&objs)
}
/// Which kids of check box `name` are ticked, and its value.
fn ticked(doc: &Document, name: &str) -> (Vec<bool>, Vec<String>) {
let all = fields(doc);
let f = field(&all, name);
((0..f.widgets.len()).map(|i| f.widget_on(i)).collect(), f.value.clone())
}
/// ISO 32000-2 §12.7.5.2.3: a check box field's `/V` is the on state of its kid that is on, and
/// its kids can have different on states (Table 230). Naming `B` ticks `B` and clears `A`;
/// "checked" keeps the kid that is on; a reset goes back to `/DV /B`. Kids sharing `Yes` tick
/// together (EXAMPLE 2).
#[test]
fn check_box_kids_with_different_on_states_tick_one_at_a_time() {
let mut doc = check_box_kids();
set_value(&mut doc, "pair", &FieldValue::Text("A".into())).unwrap();
assert_eq!(ticked(&doc, "pair"), (vec![true, false], vec!["A".to_string()]));
set_value(&mut doc, "pair", &FieldValue::Text("B".into())).unwrap();
let mut doc = reopen(&doc);
assert_eq!(ticked(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "B, after a save");
set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap();
assert_eq!(ticked(&doc, "pair").0, [false, true], "checked keeps the kid that is on");
set_value(&mut doc, "pair", &FieldValue::Check(false)).unwrap();
assert_eq!(ticked(&doc, "pair"), (vec![false, false], vec![]));
set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap();
assert_eq!(ticked(&doc, "pair").0, [true, false], "from off, checked takes the first kid");
reset(&mut doc, None).unwrap();
assert_eq!(ticked(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "reset goes back to /DV /B");
// Kids sharing an on state tick and untick together.
set_value(&mut doc, "same", &FieldValue::Text("Yes".into())).unwrap();
assert_eq!(ticked(&doc, "same"), (vec![true, true], vec!["Yes".to_string()]));
set_value(&mut doc, "same", &FieldValue::Check(false)).unwrap();
assert_eq!(ticked(&doc, "same").0, [false, false]);
assert!(matches!(set_value(&mut doc, "pair", &FieldValue::Text("C".into())), Err(FormError::Invalid(_))));
// A script that sets the value to B ticks B.
let to_b = FieldChange { name: "pair".into(), value: Some(vec!["B".into()]), ..FieldChange::default() };
set_value(&mut doc, "pair", &FieldValue::Text("A".into())).unwrap();
apply_script_changes(&mut doc, &[to_b], &mut NoScripts).unwrap();
assert_eq!(ticked(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "a script's B");
}
+17 -10
View File
@@ -222,24 +222,31 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
redraw(doc, f, std::slice::from_ref(t), scripts)
}
(FieldKind::CheckBox, v) => {
let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into());
let on = match v {
FieldValue::Check(on) => *on,
// The on-state name or a yes/no word also work (agents, FDF-style data).
// The kids of one check box field can have different on states, and the field's
// value is the on state of the kid that is on (ISO 32000-2 §12.7.5.2.3, Table 230).
// "Checked" keeps the state that is on, else takes the first kid's.
let first = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into());
let checked = || (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.clone()).unwrap_or(first.clone());
let state = match v {
FieldValue::Check(on) => on.then(checked),
// An on-state name (or `/Opt` export value) turns on the kids that have it; a
// yes/no word also works (agents, FDF-style data).
FieldValue::Text(t) | FieldValue::Radio(Some(t)) => {
let t = t.trim();
if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
true
if let Some(s) = f.state_for(t) {
Some(s.to_owned())
} else if t == first || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
Some(checked())
} else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) {
false
None
} else {
return invalid(format!("{:?} is a check box: use true or false", f.name));
}
}
FieldValue::Radio(None) => false,
FieldValue::Radio(None) => None,
FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)),
};
set_states(doc, f, on.then_some(state.as_str()))
set_states(doc, f, state.as_deref())
}
(FieldKind::Radio, v) => {
let choice = match v {
@@ -643,7 +650,7 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, Form
}
let v = match f.kind {
FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()),
FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()),
FieldKind::CheckBox => f.check_box_value(f.default.first().map(String::as_str)),
FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()),
FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()),
FieldKind::PushButton | FieldKind::Signature => continue,
+16 -8
View File
@@ -134,10 +134,18 @@ fn select_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
(w.on_state.as_ref() != f.value.first()).then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(w.on_state.clone()) })
}
/// What clicking a check box or radio button does.
fn toggle_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
/// What clicking widget `wi` of a check box or radio button does.
fn toggle_edit(f: &FormField, wi: usize) -> Option<Edit> {
let w = f.widgets.get(wi)?;
let value = match f.kind {
FormFieldKind::CheckBox => FieldValue::Check(f.value.is_empty()),
// A check box's kids can have different on states (ISO 32000-2 §12.7.5.2.3, Table 230):
// clicking one that is off turns on its state (it, and any kid sharing that state, on;
// the others off); clicking one that is on turns the field off.
FormFieldKind::CheckBox => match &w.on_state {
Some(s) if !f.widget_on(wi) => FieldValue::Text(s.clone()),
Some(_) => FieldValue::Check(false),
None => FieldValue::Check(f.value.is_empty()),
},
FormFieldKind::Radio => {
let on = w.on_state.clone();
// Clicking the selected button again turns it off unless the group forbids it.
@@ -507,7 +515,7 @@ pub(crate) fn paint_page(
painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside);
}
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
paint_unbaked_widget(painter, xf, f, wi, w, r);
} else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
@@ -566,7 +574,7 @@ pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget,
if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) }
}
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) {
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, wi: usize, w: &FormWidget, r: Rect) {
match f.kind {
FormFieldKind::Text | FormFieldKind::Combo => {
let text = f.value.first().map(String::as_str).unwrap_or("");
@@ -625,7 +633,7 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField,
FormFieldKind::CheckBox => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let checked = f.value.first().is_some_and(|v| v != "Off" && !v.is_empty());
let checked = if w.on_state.is_some() { f.widget_on(wi) } else { f.value.first().is_some_and(|v| v != "Off" && !v.is_empty()) };
if checked {
let sz = (r.height() * 0.75).clamp(8.0, 32.0);
painter.text(r.center(), egui::Align2::CENTER_CENTER, "✔", egui::FontId::proportional(sz), Color32::BLACK);
@@ -975,11 +983,11 @@ pub(crate) fn overlay(
} else if toggle && view.pending_edit.is_some() {
view.forms.toggle = true;
} else if toggle {
edit = toggle_edit(f, w);
edit = toggle_edit(f, focus.widget);
} else if space {
// Space toggles a check box and selects a radio button, as in browsers: it never
// turns the selected button off.
edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) };
edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, focus.widget) };
} else if let Some(forward) = step {
edit = step_radio(view, f, focus.widget, forward);
}
+70
View File
@@ -1576,3 +1576,73 @@ fn a_form_button_shows_and_hides_the_attachments_panel() {
h.run_steps(2);
assert_eq!(h.state().right, None);
}
/// Which kids of check box `name` are ticked (their `/AS` is their on state).
fn ticked(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec<bool> {
let s = h.state();
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().clone();
(0..f.widgets.len()).map(|i| f.widget_on(i)).collect()
}
/// Check box fields of two kids each: `pair` with the on states A and B, `same` with Yes twice.
fn check_box_kids() -> Harness<'static, PdfCraftApp> {
let kid = |parent: u32, y: u32, x: u32, state: &str| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} {y} {} {}] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>",
x + 16,
y + 16
)
};
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 10 0 R 11 0 R] >>".into(),
"<< >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(),
"<< /FT /Btn /T (pair) /V /Off /Kids [7 0 R 8 0 R] >>".into(),
kid(6, 350, 20, "A"),
kid(6, 350, 60, "B"),
"<< /FT /Btn /T (same) /V /Off /Kids [10 0 R 11 0 R] >>".into(),
kid(9, 300, 20, "Yes"),
kid(9, 300, 60, "Yes"),
];
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
let bytes = pdf_from_objects(&objs);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("kids.pdf", None, bytes).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
app
});
h.run_steps(6);
h
}
/// ISO 32000-2 §12.7.5.2.3: a check box field's value is the on state of the kid that is on, and
/// its kids can have different on states (Table 230). Clicking B after A ticks B and clears A;
/// clicking B again clears the field; kids sharing an on state tick together (EXAMPLE 2).
#[test]
fn clicking_check_box_kids_with_different_on_states() {
let mut h = check_box_kids();
click_field(&mut h, "pair", 0);
assert_eq!((ticked(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]));
click_field(&mut h, "pair", 1);
assert_eq!((ticked(&h, "pair"), value(&h, "pair")), (vec![false, true], vec!["B".to_string()]), "B ticks and A clears");
click_field(&mut h, "pair", 1);
assert_eq!((ticked(&h, "pair"), value(&h, "pair")), (vec![false, false], vec![]), "clicking B again clears the field");
click_field(&mut h, "pair", 1);
click_field(&mut h, "pair", 0);
assert_eq!((ticked(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]), "the tick moves back to A");
click_field(&mut h, "same", 0);
assert_eq!(ticked(&h, "same"), [true, true], "kids sharing Yes tick together");
click_field(&mut h, "same", 1);
assert_eq!(ticked(&h, "same"), [false, false], "and untick together");
// Space on the focused kid toggles that kid too.
click_field(&mut h, "pair", 1);
assert_eq!(value(&h, "pair"), ["B"]);
h.key_press(egui::Key::Space);
h.run_steps(3);
assert!(value(&h, "pair").is_empty(), "Space clears the ticked kid");
}
+42
View File
@@ -2099,3 +2099,45 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() {
let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count();
assert_eq!(popups, 0);
}
/// A check box field whose two kids have the on states `A` and `B`.
fn check_box_kids() -> Document {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
"<< /Fields [5 0 R] >>",
"<< /FT /Btn /T (pair) /V /Off /Kids [6 0 R 7 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [50 700 64 714] /P 3 0 R /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [80 700 94 714] /P 3 0 R /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// Imported data that names a check box kid's on state ticks that kid (ISO 32000-2 §12.7.5.2.3:
/// the field's value is the on state of the kid that is on), not the first kid.
#[test]
fn imported_values_tick_the_check_box_kid_they_name() {
let mut src = check_box_kids();
set_value(&mut src, "pair", &pdfcraft_forms::FieldValue::Text("B".into())).unwrap();
let xfdf = export_xfdf(&src, false, true, "form.pdf");
let mut dst = check_box_kids();
import(&mut dst, xfdf.as_bytes()).unwrap();
let all = fields(&dst);
let pair = all.iter().find(|f| f.name == "pair").unwrap();
assert_eq!(pair.value, ["B"], "{xfdf}");
assert_eq!((pair.widget_on(0), pair.widget_on(1)), (false, true));
}
+1 -1
View File
@@ -219,7 +219,7 @@ pub(crate) fn apply_values(doc: &mut Document, values: &[(String, Vec<String>)],
let first = vals.first().cloned().unwrap_or_default();
let v = match f.kind {
FieldKind::Text => FieldValue::Text(first),
FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"),
FieldKind::CheckBox => f.check_box_value(Some(first.as_str())),
FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)),
// XML, CSV and text hold a multi-select list's values in one cell, joined by ", "
// (as export_data writes them); split it unless the whole cell is one option.
+2 -2
View File
@@ -4523,8 +4523,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.fields"]
tools = ["form_fill"]
evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round"]
notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too"
evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round", "crates/forms/src/tests.rs::check_box_kids_with_different_on_states_tick_one_at_a_time", "crates/ui-egui/tests/it/forms.rs::clicking_check_box_kids_with_different_on_states", "crates/automation/tests/it/automation.rs::form_fill_ticks_one_kid_of_a_check_box_by_its_on_state", "crates/xfdf/src/tests.rs::imported_values_tick_the_check_box_kid_they_name"]
notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too. A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3, Table 230) ticks the kid clicked and clears the others, and a second click clears the field; kids sharing an on state tick together. Named values, a reset to /DV, script changes and imported form data tick the kid whose on state they name; form_fill takes the name and form_fields lists the options and the checked one. Not yet: two same-name field dictionaries (rather than kids) whose on states differ share only the first one's state"
[[feature]]
id = "form.fill-choice"