From 8b9d5017fe025967708b7a07ccdd641f4298f67a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 19:54:02 +0200 Subject: [PATCH 1/2] Check boxes and radio buttons draw their mark in the field's text colour (fixes #4261) --- crates/forms/src/appearance.rs | 22 ++++++++++-------- crates/forms/src/author.rs | 4 ++-- crates/forms/src/lib.rs | 2 +- crates/forms/src/tests.rs | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..488237374 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -600,8 +600,10 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). +/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the +/// Appearance tab's Text Color (#4261). /// The streams are added to `doc` as indirect objects (streams can't be direct objects). -pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { +pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str, f_da: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); @@ -609,6 +611,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name let (width, height) = (place.layout_w, place.layout_h); let (frame_c, _) = frame(doc, &wd, width, height); let style = crate::author::CheckStyle::of_widget(doc, &wd, kind); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da)); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); @@ -617,7 +620,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); - let mark = check_mark(style, width / 2.0, height / 2.0, s); + let mark = check_mark(style, width / 2.0, height / 2.0, s, &da.color); let mut nd = Dict::new(); nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); @@ -627,12 +630,13 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name } /// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is -/// `s`, drawn in black. -fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String { +/// `s`, drawn in the fill colour operation `color` (`0 g`, `0 0 1 rg`). +fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: &str) -> String { use crate::author::CheckStyle; + let (fill, stroke) = (color, stroke_op(color)); // A filled polygon through `pts`. let polygon = |pts: &[(f64, f64)]| { - let mut c = String::from("0 g\n"); + let mut c = format!("{fill}\n"); for (i, (x, y)) in pts.iter().enumerate() { c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" })); } @@ -642,7 +646,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let line = n((s * 0.1).max(1.0)); match style { CheckStyle::Check => format!( - "0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + "{stroke}\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", n(cx - s * 0.28), n(cy), n(cx - s * 0.08), @@ -653,7 +657,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str CheckStyle::Cross => { let d = s * 0.25; format!( - "0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", + "{stroke}\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", n(cx - d), n(cy - d), n(cx + d), @@ -666,7 +670,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } CheckStyle::Square => { let d = s * 0.22; - format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) + format!("{fill}\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) } CheckStyle::Diamond => { let d = s * 0.3; @@ -688,7 +692,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let r = s * 0.25; let k = 0.5523 * r; format!( - "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + "{fill}\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", n(cx + r), n(cy), n(cx + r), diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index c3424050d..ad6495948 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -666,7 +666,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel caption: None, list: None, }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } _ => { @@ -709,7 +709,7 @@ fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result } FieldKind::CheckBox | FieldKind::Radio => { let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on) + appearance::check_box_states(doc, w, f.kind, &on, &f.da) } FieldKind::PushButton => { let (s, m) = button_appearance(doc, w, &f.da); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index d120c3cd3..d6044d980 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1191,7 +1191,7 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); if !has_ap { let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..38076165a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1726,6 +1726,47 @@ fn check_box_styles_change_the_mark() { ); } +#[test] +fn check_mark_takes_the_fields_text_colour() { + // #4261: Appearance ▸ Text Color is written to /DA; the mark of a check box or radio button + // is drawn in it (Acrobat does the same), black when the /DA has no colour. + let mut doc = one_page(); + let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap(); + let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap(); + let on_ap = |doc: &Document, name: &str| { + let all = fields(doc); + let w = &field(&all, name).widgets[0]; + let on = w.on_state.clone().unwrap(); + let n = doc + .get(w.obj) + .as_dict() + .unwrap() + .get(b"AP") + .unwrap() + .as_dict() + .unwrap() + .get(b"N") + .unwrap() + .as_dict() + .unwrap() + .reference(on.as_bytes()) + .unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + assert!(on_ap(&doc, &check).contains("0 G"), "black by default"); + let blue = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }; + for name in [&check, &radio] { + set_props(&mut doc, name, &blue).unwrap(); + let ap = on_ap(&reopen(&doc), name); + assert!(ap.contains("0 0 1 RG") || ap.contains("0 0 1 rg"), "{name}: the mark is blue: {ap}"); + assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); + } + // A style change redraws too, and keeps the colour. + set_props(&mut doc, &check, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap(); + assert!(on_ap(&doc, &check).contains("0 0 1 rg"), "a square is blue as well"); +} + #[test] fn ordering_tabs_manually() { let mut doc = one_page(); From 6062535e0eea73e2e9f8cf22661f7b706590e3bd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:06:20 +0200 Subject: [PATCH 2/2] Test: the check strokes and the circle fills in the field's colour (#4261) --- crates/forms/src/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 38076165a..b1c66f2cb 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1759,7 +1759,9 @@ fn check_mark_takes_the_fields_text_colour() { for name in [&check, &radio] { set_props(&mut doc, name, &blue).unwrap(); let ap = on_ap(&reopen(&doc), name); - assert!(ap.contains("0 0 1 RG") || ap.contains("0 0 1 rg"), "{name}: the mark is blue: {ap}"); + // The check strokes, the radio's default circle fills. + let op = if name == &check { "0 0 1 RG" } else { "0 0 1 rg" }; + assert!(ap.contains(op), "{name}: the mark is blue: {ap}"); assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); } // A style change redraws too, and keeps the colour.