PDF import: keep one tracking value on letters whose file widths round off the installed font's advances

Cause: place_each (crates/pdf/src/import_text.rs, #3715) works out each
character's tracking as the file's distance between glyph origins minus
the installed font's advance. A PDF gives widths to the thousandth of an
em (the standard-14 AFM metrics or /Widths), so with a font of 2048 units
an em (Helvetica on macOS) every character is a hair (~0.0002 em) off.
When the line isn't evenly spaced as a whole (a TJ word gap, a space read
from a gap), `even` keeps every character's own value, rounding noise
included, and restyled() splits the line into a run per character.
text_becomes_point_type failed on macOS; Linux CI passed because Helvetica
isn't installed there, its stand-in's widths differ by more than 1% and
place_each falls back to the line-wide fit.

Change: measure the spacing from the file's own widths: each character's
pen is moved by how much wider the file says the characters before it are
than the font's advances, where the file gives the character's width and
the layout sets it as a glyph of its own. The tracking is then the file's
(Tc, Tw, TJ), whatever font is installed; the layout check that the glyphs
land within 0.02 em of the file's places is unchanged.

Verification: text_becomes_point_type and all of tests_text_placement pass
on macOS. New test widths_rounded_off_the_installed_fonts_advances_keep_one_tracking
uses built-in Inter (non-embedded, widths rounded to thousandths), so it
doesn't depend on system fonts; without the fix it fails on any platform
with runs N/e/x/t/ /li/n/e/ /h/e/re. cargo xtask ci: all 10 steps passed.
This commit is contained in:
Taha-Mahmoodi
2026-10-23 09:22:20 +07:00
committed by bflatastic
parent 5c3db3cef8
commit b87cda7d4b
3 changed files with 54 additions and 3 deletions
+27 -3
View File
@@ -622,7 +622,8 @@ fn restyled(t: &TextObject, kern: Option<f64>, own: &[f64]) -> Vec<TextRun> {
/// The runs of `t` that put its glyphs where the file did (#722), and their layout: with the
/// font's kerning or none, whichever the file's spacing follows, and tracking that is the same
/// after every character when the file spaced them evenly, else each character's own (word
/// spacing, `TJ` adjustments, character tightening, spaces read from gaps).
/// spacing, `TJ` adjustments, character tightening, spaces read from gaps), measured from the
/// file's widths (not the font's advances, which may be a hair off them).
///
/// The file put the glyph of each character with a [`Slot`] at `baseline` (one origin per glyph),
/// measured along `path` (type on a path: one curve per glyph) or along `line`'s baseline from its
@@ -735,14 +736,37 @@ fn place_each(
if !same_font {
return None;
}
// Where the glyphs go on the file's own widths: each character's pen moved by how much wider
// the file says the characters before it are than the font's advances (a file gives widths to
// the thousandth of an em, so a font of 2048 units an em is a hair off), where the file gives
// a character's width and the layout sets it as a glyph of its own. Spacing measured from them
// is the file's (its `Tc`, `Tw`, `TJ`): measured from the installed font's advances, each
// character would also take its own hair of tracking (a run each), and keep it once a font
// rounded otherwise is installed.
let end = pen;
let mut wider = 0.0;
let mut drift = Vec::with_capacity(chars.len());
for (i, t) in target.iter().enumerate() {
drift.push(wider);
let (Some(Some((start, _))), Some((_, Some(width)))) = (unkerned_pens.get(i), t) else { continue };
let next = match unkerned_pens.get(i + 1) {
Some(n) => n.map(|(p, _)| p),
None => Some(end),
};
if let Some(next) = next {
wider += width - (next - start);
}
}
let by_file =
|pens: &[Option<(f64, f64)>]| -> Vec<Option<(f64, f64)>> { pens.iter().zip(&drift).map(|(p, d)| p.map(|(x, a)| (x + d, a))).collect() };
// The font's kerning when the file followed it (or there is none to follow), else none.
let kerned = spacing(&pens(&auto, &chars))?;
let kerned = spacing(&by_file(&pens(&auto, &chars)))?;
let (kern, own) = match even(&kerned) {
Some(own) => (None, own),
// Without kerning the line lays out the same.
None if !kerned_any => (None, kerned),
None => {
let unkerned = spacing(&unkerned_pens)?;
let unkerned = spacing(&by_file(&unkerned_pens))?;
match even(&unkerned) {
Some(own) => (Some(0.0), own),
None if changes(&unkerned) < changes(&kerned) => (Some(0.0), unkerned),
+26
View File
@@ -563,6 +563,32 @@ fn text_boxes_word_spaced_throughout_open_as_one() {
lands(&content, "", &[PARAGRAPH_TEXT]);
}
/// A font the file names but doesn't carry, drawn with the installed one of that name, whose
/// advances (2048 units an em) its widths give only to the thousandth of an em: the spacing the
/// file set is measured from its widths, so letters a hair off the font's widths still take one
/// tracking value, not each character its own (a run each). Inter is built in, so this doesn't
/// depend on the fonts installed.
#[test]
fn widths_rounded_off_the_installed_fonts_advances_keep_one_tracking() {
let inter = FontDb::global().find_postscript("Inter-Regular").expect("Inter is built in");
let upm = inter.units_per_em();
assert!(upm != 1000.0, "advances finer than the file's widths: {upm}");
let widths: Vec<String> = (32u8..=126).map(|c| (inter.advance(inter.glyph_for(c as char)) * 1000.0 / upm).round().to_string()).collect();
let font = format!(
"<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /Encoding /WinAnsiEncoding >>",
widths.join(" ")
);
let resources = format!("/Font << /IN {} 0 R >>", first_extra(1));
let content = "BT /IN 12 Tf 20 50 Td [(Next) -1000 (line here)] TJ ET";
let bytes = pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, content) }], &[&font], None);
let t = texts(&open_as(&bytes, TextAs::Text));
assert_eq!(t.len(), 1);
let runs: Vec<&str> = t[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(runs, ["Next", " ", "line here"], "{:?}", t[0].runs);
assert!(t[0].runs[0].style.tracking.abs() < 0.5 && t[0].runs[2].style.tracking.abs() < 0.5, "{:?}", t[0].runs);
assert!(t[0].runs[1].style.tracking > 500.0, "the em's gap on the space: {:?}", t[0].runs);
}
/// A text-heavy page: `columns` columns of `rows` lines of 10 pt type, alternately plain lines and
/// paragraphs wrapped in a frame (left aligned, and justified with word spacing).
fn text_heavy(columns: usize, rows: usize) -> Vec<u8> {
+1
View File
@@ -130,6 +130,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | PDF import: a line's spacing is measured from the file's own glyph widths, so type in a font the file doesn't carry, drawn with an installed font whose advances its widths round off (Helvetica on macOS: 2048 units an em), keeps one tracking value on its letters instead of a run per character (#722 follow-up); test with built-in Inter, independent of the fonts installed; scores and hours unchanged |
| 2026-10-22 | minor | EPS import: a CR LF ending after `readline` counts as one line ending (PostScript Language Reference §3.8), so counted metadata that follows it is no longer shifted by a byte and the editable vectors import instead of the preview; scores and estimates unchanged. |
| 2026-10-21 | minor | PDF export: flattening for PDF/X-1a and PDF/X-3 in a CMYK document draws the areas it rasterizes as ink amounts (a CMYK image), so a CMYK colour under an image with see-through pixels keeps its numbers, K included, instead of going through RGB and back (#2160); own-fixture test; scores and hours unchanged |
| 2026-10-21 | minor | EPS and `.ai` editing data: type with an art style of several fills or strokes, effects on a fill (Offset Path, Transform with copies, Drop Shadow, Gaussian Blur, Bas Relief), pattern and gradient fills, per-fill blend modes and a Warp comes in as type with that live appearance instead of its drawn look (outlines), the style read as a tree of filters; other objects keep the rule for plain styles. Point type whose frame has no matrix (written so by newer files) is read; such stories were left out. Where a styled object's drawn look lay, the page isn't held to look like VectorCraft's drawing of the style. `editingData: "always"` also opens an Illustrator EPS from its editing data. Own-fixture tests; a user's file used locally only; scores and hours unchanged |