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##"
+
+10
+10
+"##;
+ 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