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.
This commit is contained in:
Eric Kaden
2026-10-19 13:56:03 -04:00
parent 459379ef9b
commit 162a891673
6 changed files with 119 additions and 9 deletions
+3 -3
View File
@@ -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,
+8 -1
View File
@@ -155,6 +155,8 @@ pub struct FontSpec {
pub italic: Option<bool>,
pub underline: Option<bool>,
pub color: Option<Color>,
/// `fontHorizontalScale`, as a fraction (0.9 for `90%`).
pub h_scale: Option<f64>,
}
/// 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<bool>,
pub underline: Option<bool>,
pub size: Option<f64>,
/// `xfa-font-horizontal-scale`, as a fraction.
pub h_scale: Option<f64>,
/// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value.
pub embed: Option<String>,
}
+20 -1
View File
@@ -44,6 +44,13 @@ fn attr_measure(n: XmlNode, name: &str) -> Option<f64> {
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<f64> {
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<XmlNode<'a, 'd>> {
n.children().find(|c| c.is_element() && c.tag_name().name() == name)
}
@@ -148,6 +155,7 @@ fn parse_font(n: Option<XmlNode>) -> 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<bool>,
underline: Option<bool>,
size: Option<f64>,
h_scale: Option<f64>,
}
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);
+8 -2
View File
@@ -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 };
+66
View File
@@ -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:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<draw name="narrow" w="{w}pt" h="0.5in"><value><text>{TEXT}</text></value><font typeface="Arial" size="10pt" fontHorizontalScale="80%"/></draw>
<draw name="plain" w="{w}pt" h="0.5in"><value><text>{TEXT}</text></value><font typeface="Arial" size="10pt"/></draw>
<draw name="rich" w="7in" h="0.3in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Full <span style="xfa-font-horizontal-scale:50%">half</span></p></body></exData></value><font typeface="Arial" size="10pt"/></draw>
</subform>
</template></xdp:xdp>"##
);
(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<i64> = 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();
+14 -2
View File
@@ -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
}
}