Merge #4496: M6: a check box's kids with different on states tick one at a time (form.fill-checkbox-radio)

This commit is contained in:
bflatastic committed 2026-11-09 18:14:35 +07:00
commit c38db36ae4
12 files changed
+339 -18

No files matched your search

+24
View File
@@ -310,6 +310,30 @@ impl Automation {
json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).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. One option
// per distinct on state: kids sharing a state tick together (EXAMPLE 2), so
// their `/Opt` exports are not separate choices. Exports name the options
// unless two states share one (Table 230 allows it) or an export would tick
// another state (form_fill reads a name as an on state first); then the
// states do.
FormFieldKind::CheckBox => {
let mut seen = std::collections::HashSet::new();
let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect();
if states.len() > 1 {
let mut exports = std::collections::HashSet::new();
let by_export = states.iter().all(|s| {
let e = f.export_for_state(s);
exports.insert(e) && f.state_for(e) == Some(*s)
});
let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() };
// The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3
// makes `/AS` decide where it and `/V` differ.
let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref());
o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::<Vec<_>>()));
o.insert("checked".into(), json!(on.map(name)));
}
}
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
@@ -508,13 +508,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). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there).")
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). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there). 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; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other 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; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other 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.")
+107
View File
@@ -3091,6 +3091,113 @@ fn form_fill_picks_one_of_radio_buttons_sharing_an_on_state() {
assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "pick": bad } })), Err(ToolError::InvalidArgs(_))), "{bad}");
}
}
/// `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);
}
/// The options `form_fields` reports fill back through `form_fill` to the kid they name, also
/// where one kid's `/Opt` export is the other kid's on state: B exports `(A)`, and filling `A`
/// still ticks the kid whose on state is A (ISO 32000-2 §12.7.5.2.3, Table 230).
#[test]
fn form_fill_round_trips_the_reported_options_of_a_check_box_with_a_crossed_export() {
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 /Opt [(x) (A)] /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 >>
stream
endstream",
];
let dir = workdir("check-box-crossed-export-fill");
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 }));
let options = before["fields"][0]["options"].clone();
assert_eq!(options, json!(["A", "B"]), "{before}");
for option in ["A", "B"] {
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": option } }));
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!(after["fields"][0]["checked"], json!(option), "filling {option}: {after}");
}
}
/// `form_fields` on a check box's kids: one option per distinct on state, named by its `/Opt`
/// export unless two states share one, and `checked` follows the kid whose `/AS` is on, which
/// ISO 32000-2 §12.7.5.2.3 makes decide where `/AS` and `/V` differ.
#[test]
fn form_fields_lists_a_check_box_by_on_state_and_reports_the_kid_that_is_on() {
// Each kid is (on state, /AS).
let listed = |name: &str, field: &str, (sa, asa): (&str, &str), (sb, asb): (&str, &str)| {
let kid = |on: &str, state: &str, x: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /{state} /Rect [{x} 250 {} 266] /P 3 0 R /AP << /N << /{on} 8 0 R /Off 8 0 R >> >> >>",
x + 16
)
};
let (wa, wb) = (kid(sa, asa, 10), kid(sb, asb, 40));
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) >>",
field,
&wa,
&wb,
"<< /Length 0 >>\nstream\n\nendstream",
];
let dir = workdir(name);
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 r = ok(&mut a, "form_fields", json!({ "doc": doc }));
(r["fields"][0]["options"].clone(), r["fields"][0]["checked"].clone())
};
// Kids sharing the on state Yes tick together, whatever their exports: not two options.
let shared =
listed("check-box-shared-state", "<< /FT /Btn /T (pair) /V /Off /Opt [(x) (y)] /Kids [6 0 R 7 0 R] >>", ("Yes", "Off"), ("Yes", "Off"));
assert_eq!(shared, (Value::Null, Value::Null));
// /V names B, but A's /AS is on: A is the one ticked.
let differ = listed("check-box-as-wins", "<< /FT /Btn /T (pair) /V /B /Kids [6 0 R 7 0 R] >>", ("A", "A"), ("B", "Off"));
assert_eq!(differ, (json!(["A", "B"]), json!("A")));
// Two on states with one export: the states name the options.
let same = listed("check-box-same-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (x)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B"));
assert_eq!(same, (json!(["A", "B"]), json!("B")));
// B's export is A's on state, so naming it would tick A: the states name the options.
let crossed = listed("check-box-crossed-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (A)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B"));
assert_eq!(crossed, (json!(["A", "B"]), json!("B")));
}
/// 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]
+11
View File
@@ -252,6 +252,17 @@ impl Field {
&& self.widgets.iter().position(|x| sharing(x) && x.state.is_none()) == Some(i)
}
/// 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
@@ -140,7 +140,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,
+59
View File
@@ -6547,6 +6547,65 @@ fn a_read_only_button_is_not_turned_on_by_number() {
assert_eq!(on_widgets(&doc, "r").0, [false, false, true]);
}
/// 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)
}
/// 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!(on_widgets(&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!(on_widgets(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "B, after a save");
set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap();
assert_eq!(on_widgets(&doc, "pair").0, [false, true], "checked keeps the kid that is on");
set_value(&mut doc, "pair", &FieldValue::Check(false)).unwrap();
assert_eq!(on_widgets(&doc, "pair"), (vec![false, false], vec![]));
set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap();
assert_eq!(on_widgets(&doc, "pair").0, [true, false], "from off, checked takes the first kid");
reset(&mut doc, None).unwrap();
assert_eq!(on_widgets(&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!(on_widgets(&doc, "same"), (vec![true, true], vec!["Yes".to_string()]));
set_value(&mut doc, "same", &FieldValue::Check(false)).unwrap();
assert_eq!(on_widgets(&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!(on_widgets(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "a script's B");
}
/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C`
/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB.
#[test]
+17 -10
View File
@@ -303,24 +303,31 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
set_states(doc, f, Some(&state), Some(*i))
}
(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(_) | FieldValue::Widget(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)),
};
set_states(doc, f, on.then_some(state.as_str()), None)
set_states(doc, f, state.as_deref(), None)
}
(FieldKind::Radio, v) => {
let choice = match v {
@@ -774,7 +781,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,
+10 -2
View File
@@ -173,8 +173,16 @@ fn select_edit(f: &FormField, wi: usize) -> 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()),
},
// Clicking the selected button again turns it off unless the group forbids it
// (NoToggleToOff, Table 229).
FormFieldKind::Radio if f.widget_on(wi) => {
@@ -678,7 +686,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);
+63
View File
@@ -1829,3 +1829,66 @@ fn a_read_only_radio_button_is_focused_but_not_selected() {
click_field(&mut h, "size", 1);
assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], Some("size is read-only".to_string())), "nor does a click");
}
/// 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!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]));
click_field(&mut h, "pair", 1);
assert_eq!((on_widgets(&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!((on_widgets(&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!((on_widgets(&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!(on_widgets(&h, "same"), [true, true], "kids sharing Yes tick together");
click_field(&mut h, "same", 1);
assert_eq!(on_widgets(&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
@@ -2269,6 +2269,48 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() {
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));
}
/// Copy: the chosen comments travel with their replies and nothing else.
#[test]
fn exporting_selected_comments_takes_them_and_their_replies() {
+1 -1
View File
@@ -837,7 +837,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
@@ -4650,8 +4650,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", "crates/forms/src/tests.rs::radio_buttons_sharing_an_on_state_are_exclusive_unless_in_unison", "crates/ui-egui/tests/it/forms.rs::clicking_a_radio_button_that_shares_its_on_state", "crates/ui-egui/tests/it/forms.rs::a_read_only_radio_button_is_focused_but_not_selected", "crates/automation/tests/it/automation.rs::form_fill_picks_one_of_radio_buttons_sharing_an_on_state"]
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. Radio buttons sharing an on state follow RadiosInUnison (ISO 32000-2 Table 229): with it they turn on together, without it only the clicked one does (its /AS; /V names the shared state), which survives a save; form_fill takes {\"widget\": n} for the same and form_fields lists on_widgets. Without NoToggleToOff a second click deselects the button. Not yet: telling such buttons apart in exported form data (FDF/XFDF carry /V only)"
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::radio_buttons_sharing_an_on_state_are_exclusive_unless_in_unison", "crates/ui-egui/tests/it/forms.rs::clicking_a_radio_button_that_shares_its_on_state", "crates/ui-egui/tests/it/forms.rs::a_read_only_radio_button_is_focused_but_not_selected", "crates/automation/tests/it/automation.rs::form_fill_picks_one_of_radio_buttons_sharing_an_on_state", "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. Radio buttons sharing an on state follow RadiosInUnison (ISO 32000-2 Table 229): with it they turn on together, without it only the clicked one does (its /AS; /V names the shared state), which survives a save; form_fill takes {\"widget\": n} for the same and form_fields lists on_widgets. Without NoToggleToOff a second click deselects the button. 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: telling such buttons apart in exported form data (FDF/XFDF carry /V only); two same-name field dictionaries (rather than kids) whose on states differ share only the first one's state"
[[feature]]
id = "form.fill-choice"