From bb4a8accdd8df8cc8f4658a3e6fa3a68bde5106b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 14:28:05 -0400 Subject: [PATCH 1/2] M12: an XFA check box without a width is as wide as its box and caption A field with no `w` is growable in width (XFA 3.3 Ch.8, growable objects): it takes its content's width, clamped to minW/maxW. The layouter gave every non-button field without a width all the room left in its container, so a positioned radio group whose choices set only minW stretched the first choice to the group's right edge. Its box (drawn at the field's right end) then sat on the last choice's box, and the group showed one circle where it had two (#5442's Yes/No groups). A check button without a width is now its box and widget margins plus its caption beside it (the caption's reserve, else its text), or the wider of the two when the caption is above or below, within what is available and clamped by minW/maxW. Other fields keep taking the available width. Test: a_radio_choice_without_a_width_is_as_wide_as_its_content, two choices with minW and no w in a positioned exclGroup; with the change reverted the Yes box lands at 105.2pt, on top of the No box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/layout/measure.rs | 19 ++++++++++++++++++- crates/xfa/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2fffb3dfa0..6532f6511e 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -51,7 +51,8 @@ impl Layouter<'_> { let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say), and so is a text label or a button (as wide as its caption), within - // what is available; any other field without one takes what is available. + // what is available, and so is a check box (its box and caption); any other field without + // one takes what is available. let w = match c.w { Some(w) => w, None => match node { @@ -65,6 +66,7 @@ impl Layouter<'_> { .as_ref() .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), + Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w), Node::Field(_) => avail_w, }, }; @@ -149,6 +151,21 @@ impl Layouter<'_> { } } + /// How wide check box `f` is without a width of its own (XFA 3.3 Ch.8, growable fields): its + /// box and widget margins, plus its caption beside it (its reserve, else its text), or the + /// wider of the two when the caption is above or below. + fn check_width(&self, f: &Field, ctx: &Ctx) -> f64 { + let check = f.check_size.unwrap_or(10.0) + f.ui_margin.horizontal(); + let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else { + return check; + }; + let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0); + match cap.placement { + Placement::Top | Placement::Bottom => check.max(text()), + _ => check + cap.reserve.unwrap_or_else(text), + } + } + /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws /// nothing. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..ff7c43fdbc 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2997,6 +2997,29 @@ fn a_radio_button_sits_where_a_check_box_would() { } } +/// A check box without a width is as wide as its box and caption, at least its minW, not the rest +/// of its group: two choices side by side in a positioned radio group keep their own boxes. +#[test] +fn a_radio_choice_without_a_width_is_as_wide_as_its_content() { + let choice = |name: &str, x: &str, item: &str| { + format!( + r#"{name}{item}"# + ) + }; + let body = format!( + r#"{}{}"#, + choice("Yes", "0mm", "Y"), + choice("No", "20.308mm", "N"), + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let laid = widgets(&form.pages[0]); + let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}")); + let mm = 72.0 / 25.4; + // Each box sits at the right end of its own 20.32mm-wide choice. + assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes")); + assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No")); +} + /// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than /// its field shrinking to fit; and a field without a height is as tall as that caption drawn in /// all the box leaves, not at some narrower width. From 56ebc5f9264001d4d8d135c1a2293d4411a4530c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 14:40:19 -0400 Subject: [PATCH 2/2] M12: pin a check box's content width and flowed packing Review follow-up. The radio-choice test only reached the minW clamp, so a wrong content formula would still pass. A check box with a reserved caption and no width or minW now must be exactly reserve + box, both positioned (its right-aligned box ends the field) and flowed (two pack side by side in an lr-tb row). Each assertion fails with the measure.rs change reverted: the box lands at 530pt, and the second flowed check box drops to the next row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/layout/measure.rs | 1 + crates/xfa/src/tests.rs | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 6532f6511e..e9405e0518 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -159,6 +159,7 @@ impl Layouter<'_> { let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else { return check; }; + // The 2pt matches `caption_reserve`'s room for a caption without a reserve. let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0); match cap.placement { Placement::Top | Placement::Bottom => check.max(text()), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index ff7c43fdbc..d382bbe644 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3015,11 +3015,34 @@ fn a_radio_choice_without_a_width_is_as_wide_as_its_content() { let laid = widgets(&form.pages[0]); let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}")); let mm = 72.0 / 25.4; - // Each box sits at the right end of its own 20.32mm-wide choice. + // A negative reserve (as Designer writes) sizes the caption to its text, so content is + // narrower than minW and each box sits at the right end of its own 20.32mm-wide choice. assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes")); assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No")); } +/// Without a width or minW, a check box is exactly its caption's reserve plus its box, whether +/// placed (its right-aligned box ends there) or flowed (two fit side by side in an lr-tb row). +#[test] +fn a_check_box_without_a_width_is_its_caption_and_box() { + let check = |name: &str, at: &str| { + format!( + r#"{name}10"# + ) + }; + let box_at = |body: String, name: &str| { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + widgets(&form.pages[0]).iter().find(|w| w.name == name).map(|w| (w.rect.x - 36.0, w.rect.y - 36.0)).unwrap() + }; + let placed = format!(r#"{}"#, check("placed", r#"x="0in" y="0in""#)); + let (x, _) = box_at(placed, "placed"); + assert!((x - 36.0).abs() < 0.01, "the box ends the 46pt field, after the 36pt caption: {x}"); + + let flowed = format!(r#"{}{}"#, check("first", ""), check("second", "")); + let (x, y) = box_at(flowed, "second"); + assert!((x - 82.0).abs() < 0.01 && y.abs() < 0.01, "the second check box follows the first on its row: ({x}, {y})"); +} + /// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than /// its field shrinking to fit; and a field without a height is as tall as that caption drawn in /// all the box leaves, not at some narrower width.