From b2f40e0442abbfd7260569e6dafae7b537a99025 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:14:42 -0400 Subject: [PATCH 1/3] M12: a space at the end of a line doesn't count when fitting XFA text Words keep the space after them, and the line-fit test counted it, so a label written with a trailing space wrapped when its box was a fraction of a point narrower than the label plus that space: DA Form 31's 'a. DATE ' and 'b. TIME ' put their second word on top of the field below. A line now fits by what it draws, and alignment ignores every trailing space, not just one. --- crates/xfa/src/tests.rs | 46 +++++++++++++++++++++++++++++++++++++++++ crates/xfa/src/text.rs | 25 ++++++++++++++++++---- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67a..c54028c26 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2067,3 +2067,49 @@ fn som_indices_count_earlier_same_named_siblings() { let sibs = SomIndices::of(&children); assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); } + +/// A draw `w` points wide holding `text` in Arial 10 pt, aligned `h_align`. +fn one_draw(text: &str, w: f64, h_align: &str) -> String { + format!( + r##""## + ) +} + +/// The text spans of the only page, with their baselines. +fn draw_spans(xdp: &str) -> Vec { + layout_xml(xdp).unwrap().pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.clone()) } else { None }).collect() +} + +#[test] +fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() { + // The box is just wide enough for "a. DATE"; the label is written "a. DATE " (as forms + // often are). The trailing space is never drawn, so the label stays on one line. + let w = pdfcraft_fonts::helvetica_width("a. DATE", 10.0) + 0.01; + let spans = draw_spans(&one_draw("a. DATE ", w, "left")); + let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect(); + assert_eq!(baselines.len(), 1, "{spans:?}"); + // Several spaces after it don't count either. + let spans = draw_spans(&one_draw("a. DATE ", w, "left")); + let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect(); + assert_eq!(baselines.len(), 1, "{spans:?}"); + // Text that is wider than the box still wraps. + let spans = draw_spans(&one_draw("a. DATES ", w, "left")); + let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect(); + assert_eq!(baselines.len(), 2, "{spans:?}"); +} + +#[test] +fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { + let w = 200.0; + let ink = pdfcraft_fonts::helvetica_width("TITLE", 10.0); + let left_edge = |align: &str, text: &str| draw_spans(&one_draw(text, w, align)).first().map(|s| s.x).unwrap(); + // The draw's left edge is the content area's (0.5in); centered and right-aligned text is + // placed by its drawn width, whatever spaces follow it. + assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6); + assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 6db0777c0..77b9bdd49 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -145,6 +145,13 @@ struct Word { } /// Break the runs of a paragraph into words (spaces stay attached to the word before them). +/// The width of `w` without the spaces it ends with: what it takes at the end of a line, where +/// a space is never drawn. +fn ink_width(w: &Word) -> f64 { + let ink = w.text.trim_end_matches(' '); + if ink.len() == w.text.len() { w.width } else { w.face.width(ink) } +} + fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec { let mut out = Vec::new(); for run in runs { @@ -197,12 +204,14 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let mut first_in_para = true; let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 }; for w in ws { - let fits = line.width + w.width <= avail - indent(line.first) || line.words.is_empty() && w.width <= avail - indent(line.first); + // A word fits when what it draws does: the spaces after it would end the line. + let ink = ink_width(&w); + let fits = line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first); if !fits && !line.words.is_empty() { let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); } - if w.width > avail - indent(line.first) && line.words.is_empty() { + if ink > avail - indent(line.first) && line.words.is_empty() { // A word longer than a line: break it by character. let mut piece = String::new(); for ch in w.text.chars() { @@ -248,8 +257,16 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let mut y = start_y + para.space_above; for ((line, align), lh) in lines.into_iter().zip(heights) { let indent = if line.first { para.text_indent.max(0.0) } else { 0.0 }; - // Trailing spaces don't count for alignment. - let trailing = line.words.last().map_or(0.0, |w| if w.text.ends_with(' ') { w.face.width(" ") } else { 0.0 }); + // Trailing spaces don't count for alignment: those of the last word with ink, and any + // words of spaces alone after it. + let mut trailing = 0.0; + for w in line.words.iter().rev() { + let ink = ink_width(w); + trailing += w.width - ink; + if !w.text.trim_end_matches(' ').is_empty() { + break; + } + } let used = (line.width - trailing).max(0.0); let mut x = rect.x + para.margin_left + indent; x += match align.unwrap_or(para.h_align) { From 6dc8ad933b8c6b2b661b6f85353986c4665e61e2 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:53:28 -0400 Subject: [PATCH 2/3] M12: spaces alone stay on an XFA text line A word of spaces alone draws nothing, but after a word whose own trailing space ran past the box it failed the fit test and opened a blank line, so the text measured a line taller than it draws. Such words now always stay on the line, keeping their width. --- crates/xfa/src/tests.rs | 4 ++++ crates/xfa/src/text.rs | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c54028c26..9784986fe 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2097,6 +2097,10 @@ fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() { let spans = draw_spans(&one_draw("a. DATE ", w, "left")); let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect(); assert_eq!(baselines.len(), 1, "{spans:?}"); + // Nor do they add a blank line: the text measures as tall as without them. + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() }); + let height = |t: &str| text::measure_height(&text::plain(t), w, &Para::default(), &face, &|_| String::new()); + assert_eq!(height("a. DATE "), height("a. DATE")); // Text that is wider than the box still wraps. let spans = draw_spans(&one_draw("a. DATES ", w, "left")); let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect(); diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 77b9bdd49..cbfd9fbc5 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -204,9 +204,12 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let mut first_in_para = true; let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 }; for w in ws { - // A word fits when what it draws does: the spaces after it would end the line. + // A word fits when what it draws does: the spaces after it would end the line. Spaces + // alone draw nothing, so they always stay on the line (keeping their width, so a word + // after them still starts past them). let ink = ink_width(&w); - let fits = line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first); + let blank = w.text.trim_end_matches(' ').is_empty(); + let fits = blank || line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first); if !fits && !line.words.is_empty() { let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); From a3b942021f8d3e28fb6b3c73ab2a920b74c5da80 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:46:45 -0400 Subject: [PATCH 3/3] M12: doc comments, and words keeps its own --- crates/xfa/src/tests.rs | 3 +++ crates/xfa/src/text.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 9784986fe..1d027162b 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2085,6 +2085,8 @@ fn draw_spans(xdp: &str) -> Vec { layout_xml(xdp).unwrap().pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.clone()) } else { None }).collect() } +/// A label that fits its box but is written with trailing spaces stays on one line and measures +/// as tall as without them; text wider than the box still wraps. #[test] fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() { // The box is just wide enough for "a. DATE"; the label is written "a. DATE " (as forms @@ -2107,6 +2109,7 @@ fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() { assert_eq!(baselines.len(), 2, "{spans:?}"); } +/// Centered and right-aligned text is placed by its drawn width, whatever spaces follow it. #[test] fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { let w = 200.0; diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index cbfd9fbc5..76035a2c4 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -144,7 +144,6 @@ struct Word { width: f64, } -/// Break the runs of a paragraph into words (spaces stay attached to the word before them). /// The width of `w` without the spaces it ends with: what it takes at the end of a line, where /// a space is never drawn. fn ink_width(w: &Word) -> f64 { @@ -152,6 +151,7 @@ fn ink_width(w: &Word) -> f64 { if ink.len() == w.text.len() { w.width } else { w.face.width(ink) } } +/// Break the runs of a paragraph into words (spaces stay attached to the word before them). fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec { let mut out = Vec::new(); for run in runs {