diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 62d7beedf..72ab40fc6 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 572675f95..8ea85ccbc 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 4582d59b2..2996b4ffb 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 cca62e64e..593c78a80 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 03eb2c67a..215b1cf3a 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 6db0777c0..f880a945c 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 } }