From 162a891673134cc6c1c323e6a1e3deec76d99bc1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:29:36 -0400 Subject: [PATCH 1/4] M12: XFA text honors its horizontal font scale fontHorizontalScale on a font, and xfa-font-horizontal-scale in rich text, condense the glyphs; PdfCraft ignored both, so condensed labels were laid out at full width and overflowed or wrapped where the form fits them on one line. Text is now measured at its scale and written with Tz, in a graphics state of its own. Field values keep full width: the forms layer draws them, and its appearances have no horizontal scaling. --- crates/xfa/src/layout.rs | 6 ++-- crates/xfa/src/model.rs | 9 +++++- crates/xfa/src/parse.rs | 21 ++++++++++++- crates/xfa/src/pdf.rs | 10 ++++-- crates/xfa/src/tests.rs | 66 ++++++++++++++++++++++++++++++++++++++++ crates/xfa/src/text.rs | 16 ++++++++-- 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 62d7beedf1..72ab40fc66 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1078,7 +1078,7 @@ impl Layouter<'_> { }; let w = (inner_w - beside - f.ui_margin.horizontal() - 4.0).max(1.0); let font = ctx.font.apply(&f.common.font); - text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font), &|_| String::new()) + text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font).unscaled(), &|_| String::new()) } fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { @@ -1589,8 +1589,8 @@ impl Layouter<'_> { } let face = match (&f.ui, &f.caption) { // Buttons show their caption in the caption's font. - (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)), - _ => Face::of(&fctx.font), + (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(), + _ => Face::of(&fctx.font).unscaled(), }; let h_align = match (&f.ui, &f.caption) { (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 572675f958..8ea85ccbcc 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -155,6 +155,8 @@ pub struct FontSpec { pub italic: Option, pub underline: Option, pub color: Option, + /// `fontHorizontalScale`, as a fraction (0.9 for `90%`). + pub h_scale: Option, } /// A font with every property resolved. @@ -166,12 +168,14 @@ pub struct Font { pub italic: bool, pub underline: bool, pub color: Color, + /// Horizontal scale of the glyphs, as a fraction (1.0: as designed). + pub h_scale: f64, } impl Default for Font { /// XFA's default font: Courier 10 pt. fn default() -> Self { - Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK } + Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK, h_scale: 1.0 } } } @@ -184,6 +188,7 @@ impl Font { italic: spec.italic.unwrap_or(self.italic), underline: spec.underline.unwrap_or(self.underline), color: spec.color.unwrap_or(self.color), + h_scale: spec.h_scale.unwrap_or(self.h_scale), } } } @@ -611,6 +616,8 @@ pub struct Run { pub italic: Option, pub underline: Option, pub size: Option, + /// `xfa-font-horizontal-scale`, as a fraction. + pub h_scale: Option, /// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value. pub embed: Option, } diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 4582d59b22..2996b4ffbc 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -44,6 +44,13 @@ fn attr_measure(n: XmlNode, name: &str) -> Option { n.attribute(name).and_then(measure) } +/// A horizontal scale such as `90%` (a bare number is a percentage too), as a fraction, within +/// 1% and 1000%; `None` when it isn't a positive number. +fn scale(v: &str) -> Option { + let v: f64 = v.trim().trim_end_matches('%').trim().parse().ok()?; + (v.is_finite() && v > 0.0).then(|| (v / 100.0).clamp(0.01, 10.0)) +} + fn child<'a, 'd>(n: XmlNode<'a, 'd>, name: &str) -> Option> { n.children().find(|c| c.is_element() && c.tag_name().name() == name) } @@ -148,6 +155,7 @@ fn parse_font(n: Option) -> FontSpec { italic: n.attribute("posture").map(|p| p == "italic"), underline: n.attribute("underline").map(|u| u != "0"), color: parse_color(child(n, "fill").and_then(|f| child(f, "color"))), + h_scale: n.attribute("fontHorizontalScale").and_then(scale), } } @@ -196,6 +204,7 @@ fn parse_rich(body: XmlNode) -> RichText { italic: Option, underline: Option, size: Option, + h_scale: Option, } fn styled(base: &Style, css: Option<&str>) -> Style { let mut s = base.clone(); @@ -207,6 +216,7 @@ fn parse_rich(body: XmlNode) -> RichText { "font-style" => s.italic = Some(v == "italic" || v == "oblique"), "text-decoration" => s.underline = Some(v.contains("underline")), "font-size" => s.size = measure(v).filter(|x| *x > 0.0), + "xfa-font-horizontal-scale" => s.h_scale = scale(v), _ => {} } } @@ -232,6 +242,7 @@ fn parse_rich(body: XmlNode) -> RichText { italic: style.italic, underline: style.underline, size: style.size, + h_scale: style.h_scale, embed: None, }, ); @@ -271,7 +282,15 @@ fn parse_rich(body: XmlNode) -> RichText { push_run( out, explicit, - Run { text: String::new(), bold: st.bold, italic: st.italic, underline: st.underline, size: st.size, embed: Some(e) }, + Run { + text: String::new(), + bold: st.bold, + italic: st.italic, + underline: st.underline, + size: st.size, + h_scale: st.h_scale, + embed: Some(e), + }, ); } walk(c, &st, out, explicit, depth + 1); diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index cca62e64e7..593c78a80d 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -612,11 +612,17 @@ impl Emitter<'_> { } let name = s.face.resource_name(); fonts.insert(name, s.face.family.base_font(s.face.bold, s.face.italic)); + // Horizontal scaling is text state, which outlives the text object: a + // condensed span gets a graphics state of its own, so the next isn't. + let scaled = (s.face.h_scale - 1.0).abs() > 1e-6; + let (open, tz, close) = + if scaled { ("q BT", format!(" {} Tz", n(s.face.h_scale * 100.0)), " ET Q") } else { ("BT", String::new(), " ET") }; content.extend( - format!("BT /{name} {} Tf {} rg 1 0 0 1 {} {} Tm ", n(s.face.size), rgb(s.face.color), n(s.x), n(h - s.baseline)).bytes(), + format!("{open} /{name} {} Tf {} rg{tz} 1 0 0 1 {} {} Tm ", n(s.face.size), rgb(s.face.color), n(s.x), n(h - s.baseline)) + .bytes(), ); content.extend(literal(&win_ansi(&s.text))); - content.extend_from_slice(b" Tj ET\n"); + content.extend_from_slice(format!(" Tj{close}\n").as_bytes()); } Item::Image { rect, data, content_type } => { let Some(r) = self.image_xobject(data, content_type) else { continue }; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67ad..215b1cf3a3 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -33,6 +33,72 @@ fn measurements_convert_to_points() { assert_eq!(measure("NaNpt"), None); } +/// Two draws of the same text, one at `fontHorizontalScale="80%"`, each 90% as wide as the +/// text is at full width, and a rich-text draw condensed to half by its span style. +fn scaled_text_template() -> (String, &'static str) { + const TEXT: &str = "Approving authority name and title"; + let w = pdfcraft_fonts::helvetica_width(TEXT, 10.0) * 0.9; + let xdp = format!( + r##""## + ); + (xdp, TEXT) +} + +#[test] +fn horizontally_scaled_text_is_measured_and_drawn_narrower() { + let (xdp, text) = scaled_text_template(); + let (tpl, _) = parse(&xdp).unwrap(); + let Node::Draw(narrow) = &tpl.root.children[0] else { panic!() }; + assert_eq!(narrow.common.font.h_scale, Some(0.8), "fontHorizontalScale is a percentage"); + let form = layout_xml(&xdp).unwrap(); + let spans: Vec<&text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect(); + let lines = |scale: f64, words: &[&str]| { + let mut b: Vec = spans + .iter() + .filter(|s| s.face.h_scale == scale && words.iter().any(|w| s.text.contains(w))) + .map(|s| (s.baseline * 100.0).round() as i64) + .collect(); + b.dedup(); + b.len() + }; + let words: Vec<&str> = text.split(' ').collect(); + // At 80% the text is 72% of its full width: it fits the 90% box on one line. + assert_eq!(lines(0.8, &words), 1, "{spans:?}"); + // At full width it doesn't: two lines. + assert_eq!(lines(1.0, &["Approving", "title"]), 2, "{spans:?}"); + // A span's own scale applies to it alone. + let half = spans.iter().find(|s| s.text.contains("half")).expect("the half span"); + let full = spans.iter().find(|s| s.text.contains("Full")).expect("the full span"); + assert_eq!((half.face.h_scale, full.face.h_scale), (0.5, 1.0)); + assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9); +} + +#[test] +fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() { + let (xdp, _) = scaled_text_template(); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let root = doc.get(doc.root().unwrap()); + let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap()); + let kid = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].clone(); + let page = doc.resolve(&kid); + let contents = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap()); + let Object::Stream(c) = &*contents else { panic!("a content stream") }; + let ops = String::from_utf8_lossy(&c.decoded().unwrap()).into_owned(); + let scaled: Vec<&str> = ops.lines().filter(|l| l.contains(" Tz ")).collect(); + assert!(!scaled.is_empty() && scaled.iter().all(|l| l.starts_with("q BT ") && l.ends_with(" ET Q")), "{ops}"); + assert!(scaled.iter().any(|l| l.contains(" 80 Tz ")) && scaled.iter().any(|l| l.contains(" 50 Tz ")), "{ops}"); + // Full-width text sets no scale, and none is left over from the condensed text before it. + assert!(ops.lines().filter(|l| l.contains(" Tj ") && !l.contains(" Tz ")).all(|l| l.starts_with("BT ")), "{ops}"); +} + #[test] fn the_template_parses_with_fonts_captions_and_rich_text() { let (tpl, warnings) = parse(&template(2)).unwrap(); diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 6db0777c06..f880a945cf 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -75,6 +75,9 @@ pub struct Face { pub size: f64, pub color: [f64; 3], pub underline: bool, + /// Horizontal scale of the glyphs (`fontHorizontalScale`), as a fraction: widths are + /// measured at it and the text is written with `Tz`. + pub h_scale: f64, } impl Face { @@ -86,6 +89,7 @@ impl Face { size: font.size.clamp(1.0, 300.0), color: font.color.0, underline: font.underline, + h_scale: font.h_scale.clamp(0.01, 10.0), } } @@ -97,20 +101,28 @@ impl Face { size: run.size.unwrap_or(self.size).clamp(1.0, 300.0), color: self.color, underline: run.underline.unwrap_or(self.underline), + h_scale: run.h_scale.unwrap_or(self.h_scale).clamp(0.01, 10.0), } } + /// This face as designed, without a horizontal scale: field text is drawn by the forms + /// layer, whose appearances have none, so it is measured that way too. + pub fn unscaled(self) -> Face { + Face { h_scale: 1.0, ..self } + } + pub fn resource_name(&self) -> &'static str { self.family.resource_name(self.bold, self.italic) } /// Advance of `s` at this face's size. pub fn width(&self, s: &str) -> f64 { - match self.family { + let designed = match self.family { Family::Courier => s.chars().count() as f64 * 0.6 * self.size, Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, Family::Times => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 0.96 } else { 0.92 }, - } + }; + designed * self.h_scale } } From dd782bca1da895f55c0f48462b6bd7d95161bb12 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:42:37 -0400 Subject: [PATCH 2/4] M12: a non-interactive XFA field is measured at its text's horizontal scale The layout draws a non-interactive field's text itself, condensed, but measured its height at full width, so a condensed value reserved lines it didn't use. Fields now measure and draw their text in one face: scaled when the layout draws it, as designed when the forms layer does (the over-page fallback included). --- crates/xfa/src/layout.rs | 14 ++++++++++++-- crates/xfa/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 72ab40fc66..6de989bf4a 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1062,6 +1062,14 @@ impl Layouter<'_> { } } + /// The face a field's text is laid out in: at its horizontal scale when the layout draws + /// the text itself (a non-interactive field), as designed when the forms layer draws it in + /// a widget, whose appearances have no horizontal scaling. + fn field_text_face(f: &Field, font: &Font) -> Face { + let face = Face::of(font); + if f.access == Access::NonInteractive { face } else { face.unscaled() } + } + /// The height the text of a multi-line text field takes (its value in the data, else the /// template's), for a field without a fixed height, which grows to hold its text. `ctx` /// is the field's own (its SOM ends in the field). Zero for anything else. @@ -1078,7 +1086,7 @@ impl Layouter<'_> { }; let w = (inner_w - beside - f.ui_margin.horizontal() - 4.0).max(1.0); let font = ctx.font.apply(&f.common.font); - text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font).unscaled(), &|_| String::new()) + text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_| String::new()) } fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { @@ -1583,7 +1591,9 @@ impl Layouter<'_> { right: f.ui_margin.right + 2.0, }; let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; - let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &Face::of(&fctx.font), &|_| String::new()); + // In the face it was measured in, so it takes the lines it was given. + let face = Self::field_text_face(f, &fctx.font); + let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &face, &|_| String::new()); return self.push_block(block); } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 215b1cf3a3..41a6781f43 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -80,6 +80,37 @@ fn horizontally_scaled_text_is_measured_and_drawn_narrower() { assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9); } +#[test] +fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() { + // A growing, non-interactive text field (drawn as text by the layout, not as a widget), + // then a draw below it. However condensed the field's text is, the draw sits the same + // distance below its last line: the field is as tall as the lines it shows. + // Long enough to take more than a multi-line field's three lines at either scale. + const TEXT: &str = "Remarks that run over several lines of a narrow box, and then over several more lines again, until the box has grown well past the three lines it starts with"; + let gap = |scale: u32| { + let xdp = format!( + r##""## + ); + let form = layout_xml(&xdp).unwrap(); + let spans: Vec<&text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect(); + let after = spans.iter().find(|s| s.text.contains("AFTER")).expect("the draw").baseline; + let lines: std::collections::BTreeSet = + spans.iter().filter(|s| !s.text.contains("AFTER")).map(|s| (s.baseline * 100.0).round() as i64).collect(); + let last = *lines.iter().next_back().expect("the field's lines") as f64 / 100.0; + (lines.len(), after - last) + }; + let (full_lines, full_gap) = gap(100); + let (narrow_lines, narrow_gap) = gap(60); + assert!(narrow_lines < full_lines, "condensed, the text takes fewer lines: {narrow_lines} vs {full_lines}"); + assert!((narrow_gap - full_gap).abs() < 0.01, "the field shrinks with its text: gap {narrow_gap} vs {full_gap}"); +} + #[test] fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() { let (xdp, _) = scaled_text_template(); From 15ab8c7a2c2ce1e84ef446ea668e8e778549a0d9 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:42:22 -0400 Subject: [PATCH 3/4] M12: doc comments for the functions this change touches --- crates/xfa/src/layout.rs | 3 +++ crates/xfa/src/model.rs | 2 ++ crates/xfa/src/parse.rs | 7 +++++++ crates/xfa/src/pdf.rs | 3 +++ crates/xfa/src/tests.rs | 7 +++++++ crates/xfa/src/text.rs | 6 ++++++ 6 files changed, 28 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 6de989bf4a..feb8a6fde3 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1464,6 +1464,9 @@ impl Layouter<'_> { if *n == 1 { base } else { format!("{base}_{n}") } } + /// Draw a field in `rect`: its border and caption, then its value as text (non-interactive + /// fields, barcodes, text longer than a page) or as a widget. `radio` puts it in an + /// exclusion group; `sib` is its index in its SOM path (`name[sib]`). fn draw_field( &mut self, f: &Field, diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 8ea85ccbcc..1c456a916e 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -180,6 +180,8 @@ impl Default for Font { } impl Font { + /// This font with the properties `spec` sets in place of its own; those `spec` leaves + /// `None` are inherited. pub fn apply(&self, spec: &FontSpec) -> Font { Font { typeface: spec.typeface.clone().unwrap_or_else(|| self.typeface.clone()), diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 2996b4ffbc..3ebdc92c73 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -146,6 +146,8 @@ fn parse_border(n: Option) -> Option { Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) }) } +/// ``: typeface, size, weight, posture, underline, fill color and horizontal scale. +/// What the element doesn't set, or a missing element, is left `None` to inherit. fn parse_font(n: Option) -> FontSpec { let Some(n) = n else { return FontSpec::default() }; FontSpec { @@ -206,6 +208,8 @@ fn parse_rich(body: XmlNode) -> RichText { size: Option, h_scale: Option, } + /// `base` with the CSS declarations in `css` applied: weight, style, underline, size and + /// `xfa-font-horizontal-scale`. Anything else is ignored. fn styled(base: &Style, css: Option<&str>) -> Style { let mut s = base.clone(); for decl in css.unwrap_or("").split(';') { @@ -224,6 +228,9 @@ fn parse_rich(body: XmlNode) -> RichText { } // Paragraph boundaries from `p` are implicit (dropped when empty); `br` makes an explicit // line break that stays even when the line is blank. + /// Add the text under `n` to `out` as runs in `style`, as the elements it meets restyle it; + /// `p` and `br` start paragraphs (`explicit` marks the `br` ones) and `embed` spans add + /// embedded fields. Nothing deeper than `MAX_DEPTH` is read. fn walk(n: XmlNode, style: &Style, out: &mut RichText, explicit: &mut Vec, depth: usize) { if depth > MAX_DEPTH { return; diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 593c78a80d..00d473f0da 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -570,6 +570,9 @@ impl Emitter<'_> { } } + /// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a + /// content stream, the fonts and images it uses as resources, and its widgets as + /// annotations. Returns the page object. fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result { let page_ref = self.doc.add(Object::Dict(Dict::new())); let mut content: Vec = Vec::new(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 41a6781f43..3d01245abd 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -51,6 +51,9 @@ fn scaled_text_template() -> (String, &'static str) { (xdp, TEXT) } +/// `fontHorizontalScale` is read as a percentage and text is measured at it: the 80% draw +/// fits its box on one line where the full-width one takes two, and a span's own scale +/// applies to that span alone. #[test] fn horizontally_scaled_text_is_measured_and_drawn_narrower() { let (xdp, text) = scaled_text_template(); @@ -80,6 +83,8 @@ fn horizontally_scaled_text_is_measured_and_drawn_narrower() { assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9); } +/// A growing non-interactive field is measured in the face its text is drawn in: condensed, +/// its text takes fewer lines and the field grows only to hold them. #[test] fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() { // A growing, non-interactive text field (drawn as text by the layout, not as a widget), @@ -111,6 +116,8 @@ fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() { assert!((narrow_gap - full_gap).abs() < 0.01, "the field shrinks with its text: gap {narrow_gap} vs {full_gap}"); } +/// Condensed text is written with `Tz` inside a `q`/`Q` pair of its own, and full-width text +/// sets no scale, so none carries over from the text before it. #[test] fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() { let (xdp, _) = scaled_text_template(); diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index f880a945cf..05071a5418 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -81,6 +81,8 @@ pub struct Face { } impl Face { + /// The face a resolved font is drawn in: the standard-14 family its typeface maps to, + /// with its size and horizontal scale clamped to a drawable range. pub fn of(font: &Font) -> Face { Face { family: Family::of(&font.typeface), @@ -93,6 +95,8 @@ impl Face { } } + /// This face with what a rich-text run sets in place of its own: weight, posture, + /// underline, size and horizontal scale. The family and color stay. pub fn with_run(&self, run: &Run) -> Face { Face { family: self.family, @@ -111,6 +115,8 @@ impl Face { Face { h_scale: 1.0, ..self } } + /// The resource name of this face's standard font, as content streams and `/DA` strings + /// refer to it. pub fn resource_name(&self) -> &'static str { self.family.resource_name(self.bold, self.italic) } From 15eef03efc6b327b792d2c03dda69d0e40236630 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:23:32 -0400 Subject: [PATCH 4/4] M12: doc comments for the functions around this change --- crates/xfa/src/layout.rs | 15 +++++++++++++++ crates/xfa/src/parse.rs | 3 +++ crates/xfa/src/pdf.rs | 3 +++ crates/xfa/src/tests.rs | 7 +++++++ 4 files changed, 28 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index feb8a6fde3..5571bf9934 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1004,6 +1004,9 @@ impl Layouter<'_> { } } + /// The height a value takes at width `w` in the context's font and paragraph: its text as + /// measured, a picture scaled to `w` keeping its shape, nothing for a line, a rectangle or + /// no value. 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)), @@ -1013,6 +1016,10 @@ impl Layouter<'_> { } } + /// Where a field's caption goes and the room it takes there: its reserve, else the height + /// of its text above or below, or beside it its text's width plus 2pt, at most 60% of + /// `inner_w`. An inline caption goes on the left. `None` for a button, a caption that takes + /// no space, or one with neither text nor a reserve. fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { let cap = f.caption.as_ref()?; if !cap.presence.occupies() || f.ui == Ui::Button { @@ -1037,6 +1044,9 @@ impl Layouter<'_> { Some((placement, reserve)) } + /// The height of a field at `inner_w`: its widget (a check box's size, or its lines of text, + /// grown to hold a long value, plus padding and the widget margin), plus a caption above or + /// below it. A caption beside it makes it at least as tall as the caption's text. fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { let font = ctx.font.apply(&f.common.font); let lines = if f.multiline { 3.0 } else { 1.0 }; @@ -1089,6 +1099,8 @@ impl Layouter<'_> { text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_| String::new()) } + /// The height of an exclusion group's fields at `inner_w`, by the group's layout: stacked + /// top to bottom, at their own positions, or packed in rows. fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); match g.layout { @@ -1457,6 +1469,9 @@ impl Layouter<'_> { self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() }) } + /// The widget name for a field or group named `base`, cleaned (dots become underscores, + /// control characters go): the first use is the name itself, later ones `name_2`, `name_3` + /// and so on. fn unique_name(&mut self, base: &str) -> String { let base = clean_name(base); let n = self.names.entry(base.clone()).or_insert(0); diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 3ebdc92c73..e11ce3060e 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -40,6 +40,8 @@ pub fn measure(s: &str) -> Option { Some((v * k).clamp(-100_000.0, 100_000.0)) } +/// The attribute `name` of `n` read as a measurement in points (see [`measure`]); `None` +/// when it is missing or unreadable. fn attr_measure(n: XmlNode, name: &str) -> Option { n.attribute(name).and_then(measure) } @@ -51,6 +53,7 @@ fn scale(v: &str) -> Option { (v.is_finite() && v > 0.0).then(|| (v / 100.0).clamp(0.01, 10.0)) } +/// The first child element of `n` named `name` (by local name, in any namespace). fn child<'a, 'd>(n: XmlNode<'a, 'd>, name: &str) -> Option> { n.children().find(|c| c.is_element() && c.tag_name().name() == name) } diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 00d473f0da..bbde05ab0d 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -509,6 +509,9 @@ impl Emitter<'_> { Some(r) } + /// Write a new image XObject for a picture, tagged with its `hash`: a JPEG as it is, a PNG + /// or GIF decoded to samples with a soft mask for any transparency. `None`, with a warning, + /// when the picture can't be read or is another kind. fn new_image_xobject(&mut self, data: &[u8], content_type: &str, hash: &[u8; 32]) -> Option { let what = if content_type.is_empty() { "picture" } else { content_type }; let mut d = Dict::new(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 3d01245abd..0645633f11 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -13,14 +13,18 @@ use crate::layout::WidgetKind; use crate::model::*; use crate::script::{NodeKind, Overrides, apply_overrides, fields_by_som, form_tree, overrides, rerender, set_overrides}; +/// The widgets laid out on `page`, in order. fn widgets(page: &Page) -> Vec<&Widget> { page.items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w.as_ref()) } else { None }).collect() } +/// The text of each text span laid out on `page`, in order. fn texts(page: &Page) -> Vec { page.items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.clone()) } else { None }).collect() } +/// Measurements in points, inches, millimeters and centimeters convert to points, a bare +/// number is points, and an `em` length, a non-number or a NaN is `None`. #[test] fn measurements_convert_to_points() { assert_eq!(measure("72pt"), Some(72.0)); @@ -137,6 +141,9 @@ fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() { assert!(ops.lines().filter(|l| l.contains(" Tj ") && !l.contains(" Tz ")).all(|l| l.starts_with("BT ")), "{ops}"); } +/// The sample template parses without warnings into what the layout uses: its page areas, +/// page-number draws, rich-text title, field font, caption and widget border, check box, +/// date field, radio group, table columns and page break all come through. #[test] fn the_template_parses_with_fonts_captions_and_rich_text() { let (tpl, warnings) = parse(&template(2)).unwrap();