mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
1 parent
f137a289ba
commit
880ccd4c82
12 files changed
+278
-24
No files matched your search
@@ -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)));
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user