diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 34fe7f29f9..8c88644894 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1131,6 +1131,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. @@ -1147,9 +1155,11 @@ 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, &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 { @@ -1657,14 +1667,16 @@ 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); } } 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 e6d737e036..4da6cebefd 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,16 +168,20 @@ 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 } } } 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()), @@ -184,6 +190,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), } } } @@ -617,6 +624,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 0415ef97a6..b9425df147 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -40,10 +40,20 @@ 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) } +/// 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)) +} + +/// 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) } @@ -139,6 +149,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 { @@ -148,6 +160,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,7 +209,10 @@ fn parse_rich(body: XmlNode) -> RichText { italic: Option, underline: Option, 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(';') { @@ -207,6 +223,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), _ => {} } } @@ -214,6 +231,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; @@ -232,6 +252,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 +292,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..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(); @@ -570,6 +573,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(); @@ -612,11 +618,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 1413acb667..0020d11913 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -16,14 +16,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)); @@ -36,6 +40,113 @@ 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) +} + +/// `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(); + 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); +} + +/// 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), + // 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}"); +} + +/// 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(); + 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}"); +} + +/// 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(); diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index c885da8c98..91be706e5b 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -75,9 +75,14 @@ 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 { + /// 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), @@ -86,9 +91,12 @@ 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), } } + /// 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, @@ -97,20 +105,30 @@ 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 } + } + + /// 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) } /// 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 } }