From 1c4a481ea30850f778674b337dc57a6ee022bfe1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:18:39 -0400 Subject: [PATCH] M12: round XFA check buttons draw a round background and border --- crates/engine/src/tests.rs | 29 ++++++++++++++ crates/forms/src/appearance.rs | 71 +++++++++++++++++++++++++++++++++- crates/forms/src/tests.rs | 43 ++++++++++++++++++++ crates/xfa/src/pdf.rs | 10 +++++ crates/xfa/src/tests.rs | 46 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 200 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..df0ed33a9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4675,6 +4675,35 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// A round XFA check button opens drawn round: the appearance its laid-out widget gets draws a +/// circle, the on state with its mark in it, while a square one keeps its box. +#[test] +fn round_xfa_check_buttons_open_drawn_round() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone(); + let all = pdfcraft_forms::fields(&cos); + // The decoded appearance of `name`'s widget in `state`. + let drawn = |name: &str, state: &[u8]| { + let w = all.iter().find(|f| f.name.ends_with(name)).unwrap_or_else(|| panic!("no field {name}")).widgets[0].obj; + let wd = cos.get(w).as_dict().cloned().unwrap(); + let n = cos.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(state).unwrap_or_else(|| panic!("{name} has no {} state", String::from_utf8_lossy(state))); + let pdfcraft_cos::Object::Stream(st) = &*cos.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&st.decoded().unwrap()).into_owned() + }; + for state in [&b"Off"[..], b"1"] { + let round = drawn("round", state); + assert!(round.contains(" c S") && !round.contains(" re S"), "the round one's border is a circle: {round}"); + assert!(drawn("square", state).contains(" re S"), "the square one keeps its box"); + } +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..7f55e192e 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -277,10 +277,14 @@ fn stroke_op(fill: &str) -> String { } } -/// Background and border from `/MK` and `/BS`; returns (content, border width). +/// Background and border from `/MK` and `/BS`; returns (content, border width). A round XFA +/// check button's ([`is_round`]) are a circle inside the widget instead of its rectangle. fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let mut c = String::new(); + if is_round(doc, wd) { + return round_frame(doc, wd, mk.as_ref(), w, h); + } if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") { c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h))); } @@ -346,6 +350,71 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { (c, 0.0) } +/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button +/// (`/PCXfaRound true`, written by the XFA layout). +fn is_round(doc: &Document, wd: &Dict) -> bool { + matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) +} + +/// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC` +/// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A +/// dashed border stays dashed; any other style is drawn solid. Returns the border width. +fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) { + let mut c = String::new(); + let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0); + if let Some(bg) = color_array(doc, mk, b"BG") { + c.push_str(&format!("{bg}\n{}f\n", circle_path(cx, cy, r))); + } + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + match color_array(doc, mk, b"BC") { + Some(bc) if bw > 0.0 => { + let dashed = bs.as_ref().and_then(|b| b.name(b"S")) == Some(b"D"); + // The stroke is centred on the path: inset by half its width, it stays inside. + let path = circle_path(cx, cy, (r - bw / 2.0).max(0.0)); + c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" })); + (c, bw) + } + _ => (c, 0.0), + } +} + +/// A closed circle of radius `r` around (`cx`, `cy`) as four Bézier arcs, ending with a space +/// so a paint operator can follow. +fn circle_path(cx: f64, cy: f64, r: f64) -> String { + // The control-point distance for a quarter circle. + let k = 0.5523 * r; + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c ", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + ) +} + /// The width [`frame`] insets the content by: the `/BS` border width when the widget has a /// border colour, doubled for beveled and inset borders, and 0 without a border. fn border_width(doc: &Document, wd: &Dict) -> f64 { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..fb48bd39e 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,49 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background +/// and border as a circle inside the widget, inset by half the border so the stroke stays in; +/// one without the key keeps its rectangle. +#[test] +fn a_round_xfa_check_button_draws_a_round_background_and_border() { + let widget = |name: &str, extra: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << /BG [0.8 0.85 1] /BC [1 0 0] >> /BS << /W 1 /S /S >>{extra} >>" + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R] >>".into(), + widget("round", " /PCXfaRound true"), + widget("square", ""), + widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), + ]; + let mut doc = document(&objs); + for name in ["round", "square", "dashed"] { + redraw_field(&mut doc, name).unwrap(); + } + // The Off state: only the frame. + let off = |name: &str| { + let all = fields(&doc); + let w = field(&all, name).widgets[0].obj; + let wd = doc.get(w).as_dict().cloned().unwrap(); + let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + let round = off("round"); + assert!(!round.contains(" re "), "no rectangle: {round}"); + assert!(round.contains("0.8 0.85 1 rg\n20 10 m"), "the background is a disc as wide as the box: {round}"); + assert!(round.contains("1 0 0 RG\n1 w\n19.5 10 m") && round.contains(" c S"), "the border is a circle inset by half its width: {round}"); + let square = off("square"); + assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}"); + let dashed = off("dashed"); + assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}"); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d88..c51879dfa 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -160,6 +160,11 @@ pub struct Written { /// layer reads this key). pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage"; +/// On a check box or radio button generated for a round XFA check button (`shape="round"`): its +/// background and border are drawn as a circle, not the widget's rectangle (the forms layer +/// reads this key). +pub const ROUND_KEY: &[u8] = b"PCXfaRound"; + /// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A /// re-layout (after every script event) finds the XObjects on the pages it replaces by this, /// and reuses them instead of decoding and embedding the same picture again. Nothing is kept @@ -244,6 +249,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); @@ -271,6 +278,9 @@ impl Emitter<'_> { bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); d.set(b"BS".to_vec(), Object::Dict(bs)); + if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { + d.set(ROUND_KEY.to_vec(), Object::Bool(true)); + } let mut ff: i64 = 0; if w.read_only { ff |= 1; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b17..e4464bbd4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,52 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A check box or radio button laid out from a round check button (`shape="round"`) carries +/// `ROUND_KEY`, so the forms layer draws its background and border round; a square one (the +/// default) doesn't. +#[test] +fn round_check_buttons_are_marked_for_a_round_appearance() { + let check = |name: &str, shape: &str, items: &str| { + format!(r#"{items}"#) + }; + let on_off = "10"; + let xdp = format!( + r##" +"##, + check("roundBox", r#" shape="round""#, on_off), + check("squareBox", "", on_off), + check("roundRadio", r#" shape="round""#, "a"), + check("squareRadio", r#" shape="square""#, "b"), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + // Every widget among the form's fields and their kids (a radio group's buttons), by the + // last step of its SOM path. + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let mut todo: Vec<_> = + doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + let mut round = std::collections::BTreeMap::new(); + while let Some(r) = todo.pop() { + let node = doc.get(r); + let d = node.as_dict().unwrap(); + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)) { + todo.extend(kids.as_array().unwrap().iter().filter_map(Object::as_ref)); + continue; + } + let som = d.get(SOM_KEY).and_then(Object::as_string).map(|s| s.to_text()).unwrap_or_default(); + let name = som.rsplit('.').next().unwrap_or_default().split('[').next().unwrap_or_default().to_string(); + round.insert(name, matches!(d.get(crate::pdf::ROUND_KEY), Some(Object::Bool(true)))); + } + let expected: std::collections::BTreeMap = [("roundBox", true), ("squareBox", false), ("roundRadio", true), ("squareRadio", false)] + .into_iter() + .map(|(n, r)| (n.to_string(), r)) + .collect(); + assert_eq!(round, expected); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..4acde8aac 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4452,8 +4452,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"] -notes = "NoToggleToOff honoured; widgets without appearances get our own. 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"] +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" [[feature]] id = "form.fill-choice"