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 1/3] 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 ed4e6efa1c..df0ed33a97 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 3b6f6f20d6..7f55e192ea 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 ea683bb67f..fb48bd39e7 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 8e0c47d888..c51879dfa3 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 411529b177..e4464bbd45 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 4ec39848c5..4acde8aaca 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" From 21f3241e6d1604b06c7f283a925c94173554210e Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:45:40 -0400 Subject: [PATCH 2/3] M12: a round border as wide as its check button fills it as a disc --- crates/forms/src/appearance.rs | 10 ++++++++-- crates/forms/src/tests.rs | 14 +++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 7f55e192ea..e5fe07eb9b 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -358,7 +358,8 @@ fn is_round(doc: &Document, wd: &Dict) -> bool { /// [`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. +/// dashed border stays dashed; any other style is drawn solid, and a border as wide as the +/// circle fills it. 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); @@ -368,10 +369,15 @@ fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> 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") { + // A border as wide as the circle fills it: a disc in the border's colour. + Some(bc) if bw > 0.0 && bw >= 2.0 * r => { + c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r))); + (c, 2.0 * r) + } 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)); + let path = circle_path(cx, cy, r - bw / 2.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) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index fb48bd39e7..4255cbac88 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -388,8 +388,8 @@ fn opt_export_values_select_button_states() { } /// 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. +/// and border as a circle inside the widget, inset by half the border so the stroke stays in, +/// or a disc when the border is as wide as the circle; 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| { @@ -400,14 +400,15 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { 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(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), widget("round", " /PCXfaRound true"), widget("square", ""), widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), + widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), ]; let mut doc = document(&objs); - for name in ["round", "square", "dashed"] { + for name in ["round", "square", "dashed", "thick"] { redraw_field(&mut doc, name).unwrap(); } // The Off state: only the frame. @@ -428,6 +429,9 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { 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}"); + // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. + let thick = off("thick"); + assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); } /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an From ac181479ef8bbd837462e0caaa83eea876ff101d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:03:53 -0400 Subject: [PATCH 3/3] M12: a round check button's underline border stays an underline --- crates/forms/src/appearance.rs | 12 +++++++++--- crates/forms/src/tests.rs | 13 +++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index e5fe07eb9b..23d00e21f2 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -358,8 +358,8 @@ fn is_round(doc: &Document, wd: &Dict) -> bool { /// [`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, and a border as wide as the -/// circle fills it. Returns the border width. +/// dashed border stays dashed and an underline stays an underline; any other style is drawn +/// solid, and a border as wide as the circle fills it. 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); @@ -368,14 +368,20 @@ fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> } 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); + let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); match color_array(doc, mk, b"BC") { + // An underline stays one: only the bottom edge, as for any widget. + Some(bc) if bw > 0.0 && style == b"U" => { + c.push_str(&format!("{}\n{} w\n0 {} m {} {} l S\n", stroke_op(&bc), n(bw), n(bw / 2.0), n(w), n(bw / 2.0))); + (c, bw) + } // A border as wide as the circle fills it: a disc in the border's colour. Some(bc) if bw > 0.0 && bw >= 2.0 * r => { c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r))); (c, 2.0 * r) } Some(bc) if bw > 0.0 => { - let dashed = bs.as_ref().and_then(|b| b.name(b"S")) == Some(b"D"); + let dashed = style == 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); 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 { "" })); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4255cbac88..8aa34f0e1d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -389,7 +389,8 @@ fn opt_export_values_select_button_states() { /// 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, -/// or a disc when the border is as wide as the circle; one without the key keeps its rectangle. +/// or a disc when the border is as wide as the circle (an underline stays one); 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| { @@ -400,15 +401,16 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { 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 8 0 R] >>".into(), - "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), widget("round", " /PCXfaRound true"), widget("square", ""), widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), + widget("underlined", " /PCXfaRound true /BS << /W 1 /S /U >>").replace("/BS << /W 1 /S /S >>", ""), ]; let mut doc = document(&objs); - for name in ["round", "square", "dashed", "thick"] { + for name in ["round", "square", "dashed", "thick", "underlined"] { redraw_field(&mut doc, name).unwrap(); } // The Off state: only the frame. @@ -432,6 +434,9 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. let thick = off("thick"); assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); + // An underline stays the bottom edge, under a round background. + let underlined = off("underlined"); + assert!(underlined.contains("1 0 0 RG\n1 w\n0 0.5 m 20 0.5 l S"), "an underline stays an underline: {underlined}"); } /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an