From 0a77db066c4aaabf1fb4a113777f3500169fad95 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:20:02 -0400 Subject: [PATCH] Forms: a radio group's read-only button is left unhighlighted on its own --- crates/forms/src/author.rs | 1 + crates/forms/src/lib.rs | 5 +++++ crates/ui-egui/src/canvas.rs | 9 +++++---- crates/ui-egui/tests/view.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 521d1f527..83a3fb9fd 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -659,6 +659,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel tab: usize::MAX, locked: false, hidden: false, + read_only: false, rotation: 0, caption: None, list: None, diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 1dfa76726..8ea7b2744 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -101,6 +101,9 @@ pub struct Widget { pub locked: bool, /// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input. pub hidden: bool, + /// Read-only: the field is, or the widget's own `/Ff` says so (a radio button of a group can + /// be locked on its own, as an XFA exclusion group's layout writes it). + pub read_only: bool, /// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0. pub rotation: i64, /// Push buttons: the normal label (`/MK /CA`), separate from the field's name. @@ -788,6 +791,7 @@ fn walk( let wd = wo.as_dict()?; let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; let on_state = wd .get(b"AP") .map(|ap| doc.resolve(ap)) @@ -803,6 +807,7 @@ fn walk( tab: usize::MAX, locked: annot_flags & 128 != 0, hidden: annot_flags & (2 | 32) != 0, + read_only: (ff | own_ff) & flags::READ_ONLY != 0, rotation: appearance::mk_rotation(doc, wd), caption: (kind == FieldKind::PushButton) .then(|| { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eaaf01624..be1f28483 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3463,13 +3463,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons are round, and so is theirs (as in Acrobat). Only fields - // that can be filled are marked: a read-only one is left as it is drawn. + // red border. Radio buttons are round, and so is theirs (as in Acrobat). Only what can + // be filled is marked: a read-only widget (its field's, or a radio button's own) is + // left as it is drawn. if view.highlight_fields { - for f in form.iter().filter(|f| !f.has(pdfcraft_engine::field_flags::READ_ONLY)) { + for f in form.iter() { let required = f.has(pdfcraft_engine::field_flags::REQUIRED); let round = f.kind == pdfcraft_engine::FormFieldKind::Radio; - for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden) { + for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) { let r = w.rect; let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 12a740f77..c3f15c4a4 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1112,6 +1112,37 @@ fn highlight_fields_leaves_read_only_fields_alone() { assert_eq!(tinted, 1, "only the field that can be filled is tinted"); } +/// A radio group whose second button is locked on its own widget (`/Ff` 1 there), as the +/// layout of an XFA exclusion group with one read-only button writes it. +const LOCKED_RADIO: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >> endobj +4 0 obj << /FT /Btn /Ff 49152 /T (choice) /V /Off /Kids [5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [50 300 90 340] /AS /Off /P 3 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /Parent 4 0 R /Ff 1 /Rect [150 300 190 340] /AS /Off /P 3 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A read-only button of a radio group is left untinted while the group's other button, which +/// can be chosen, is tinted. +#[test] +fn highlight_fields_leaves_a_groups_read_only_button_alone() { + 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("radio.pdf", None, LOCKED_RADIO.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("fields", "on").unwrap(); + app + }); + h.run_steps(6); + let tint = egui::Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let tinted = h.output().shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(r) if r.fill == tint)).count(); + assert_eq!(tinted, 1, "only the button that can be chosen is tinted"); +} + /// A required radio group (`/Ff` 32768 radio + 2 required) with two 40 pt round buttons. const RADIOS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj