From 32bb14b9524533736b32937bc9a488b8c04ac5ad Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:06:32 -0400 Subject: [PATCH] M12: an XFA label or button without a width is as wide as its text A draw or a button with no width took all the width available, so a right-aligned label slid to the far side of its subform and a button spread over what lay beside it: DA Form 31's Instructions button covered its header and the email line, and its PERSONNEL/FINANCE line ran into the field after it. They now grow to their text, unwrapped, within what is available and never below their minimum width, as containers do. --- crates/xfa/src/layout.rs | 22 ++++++++++++++-- crates/xfa/src/tests.rs | 57 ++++++++++++++++++++++++++++++++++++++++ crates/xfa/src/text.rs | 8 ++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 62d7beedf1..f44a23478c 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -912,7 +912,8 @@ impl Layouter<'_> { let ctx = ctx.with_som(c, 0); let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its - // question, say); a leaf without one takes what is available. + // 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. let w = match c.w { Some(w) => w, None => match node { @@ -920,7 +921,13 @@ impl Layouter<'_> { let inner = (avail_w - c.margin.horizontal()).max(1.0); (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) } - Node::Field(_) | Node::Draw(_) => avail_w, + Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), + Node::Field(f) if f.ui == Ui::Button => f + .caption + .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(_) => avail_w, }, }; let w = clamp_opt(w, c.min_w, c.max_w); @@ -1004,6 +1011,17 @@ impl Layouter<'_> { } } + /// 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. + fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option { + let rich = match v { + Value::Text(t) => text::plain(t), + Value::Rich(r) => r.clone(), + _ => return None, + }; + Some(text::natural_width(&rich, para, &Face::of(font), &|id| self.embed(id)) + 0.01) + } + fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 { match v { Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67ad..02bb07dfc1 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1195,6 +1195,63 @@ fn repeating_subforms_inside_positioned_content_get_their_instances() { /// treated like a subform (at most once), so once the first body page was out the layout fell /// back to the last page area, here one limited to a single use, and drew its content over every /// body page (and listed a copy of its fields per page). +/// A positioned subform holding `body`, laid out; its page's items. +fn positioned(body: &str) -> Vec { + let xdp = format!( + r##""## + ); + layout_xml(&xdp).unwrap().pages.into_iter().next().unwrap().items +} + +#[test] +fn a_label_without_a_width_is_as_wide_as_its_text() { + const TEXT: &str = "PERSONNEL ONLY: from date (YYYYMMDD):"; + let natural = pdfcraft_fonts::helvetica_width(TEXT, 8.0); + // Right-aligned, with a minimum narrower than the text: the box is as wide as the text and + // starts where it is placed, so the text does too (not at the far right of the subform). + let items = positioned(&format!( + r#"{TEXT}"# + )); + let span = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).expect("the text"); + assert!((span.x - (36.0 + 72.0)).abs() < 0.05, "starts at its x: {}", span.x); + // With a minimum wider than the text, the box is that wide and the text ends at its right. + let items = positioned(&format!( + r#"{TEXT}"#, + natural + 40.0 + )); + let span = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).expect("the text"); + assert!((span.x + span.face.width(&span.text) - (36.0 + 72.0 + natural + 40.0)).abs() < 0.05, "ends at its right: {}", span.x); + // Text longer than the room left still wraps at the container's width. + let long = "word ".repeat(200); + let items = positioned(&format!(r#"{long}"#)); + let lines: std::collections::BTreeSet = + items.iter().filter_map(|i| if let Item::Text(s) = i { Some((s.baseline * 100.0).round() as i64) } else { None }).collect(); + assert!(lines.len() > 1, "wraps: {} line(s)", lines.len()); +} + +#[test] +fn a_button_without_a_width_is_as_wide_as_its_caption() { + // No width, a minimum narrower than its caption: as wide as the caption, not the subform. + let caption = "Open the instructions for this form"; + let items = positioned(&format!( + r#"