diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index f1a5a2c51..02b4204ea 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -622,7 +622,8 @@ fn restyled(t: &TextObject, kern: Option, own: &[f64]) -> Vec { /// 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> { 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), diff --git a/crates/pdf/src/tests_text_placement.rs b/crates/pdf/src/tests_text_placement.rs index 7fd180593..1b9e2a29c 100644 --- a/crates/pdf/src/tests_text_placement.rs +++ b/crates/pdf/src/tests_text_placement.rs @@ -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 = (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 { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4e5cfe97f..dd6f5d13b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |