diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 02b4204ea..7117a14be 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -623,7 +623,8 @@ fn restyled(t: &TextObject, kern: Option, own: &[f64]) -> Vec { /// 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), measured from the -/// file's widths (not the font's advances, which may be a hair off them). +/// file's widths (not the font's advances, which may be a hair off them) and kept on course where +/// the differences add up ([`on_course`]). /// /// 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 @@ -760,7 +761,8 @@ fn place_each( 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(&by_file(&pens(&auto, &chars)))?; + let kerned_pens = pens(&auto, &chars); + let kerned = spacing(&by_file(&kerned_pens))?; let (kern, own) = match even(&kerned) { Some(own) => (None, own), // Without kerning the line lays out the same. @@ -774,11 +776,8 @@ fn place_each( } } }; + let own = on_course(own, if kern.is_some() { &unkerned_pens } else { &kerned_pens }, &target, size); let runs = restyled(t, kern, &own); - // As the type is: laid out so already. - if kern.is_none() && runs.iter().all(|r| r.style.tracking == 0.0) { - return Some((runs, auto)); - } // Laid out so, are they where the file put them? (A ligature the layout forms where the file // drew two glyphs moves those after it.) let mut probe = t.clone(); @@ -792,6 +791,65 @@ fn place_each( ok.then_some((probe.runs, check)) } +/// `own` (each character's spacing, points, measured from the file's widths) kept on course: laid +/// out from `pens` (where each character goes untracked), the glyphs land within a hundredth of an +/// em of `target` (where the file put them). The font's advances may each be a little off the +/// file's widths (by up to the 1% that still counts as the same font), and a line spaced evenly to +/// a hair takes the same value after every character: small differences that add up along a line. +/// Off course, each stretch of the line takes a share of the difference after every character +/// (one run), as long a stretch as one share keeps on course. +fn on_course(mut own: Vec, pens: &[Option<(f64, f64)>], target: &[Option<(f64, Option)>], size: f64) -> Vec { + let tolerance = size * 0.01; + // How much further along than `own` lays it out each glyph the file placed must go. + let mut at = 0.0; + let mut behind = vec![]; + for (i, ((p, t), e)) in pens.iter().zip(target).zip(&own).enumerate() { + if let (Some((p, _)), Some((t, _))) = (p, t) { + behind.push((i, t - p - at)); + } + at += e; + } + if behind.iter().all(|(_, d)| d.abs() <= tolerance) { + return own; + } + // The character the stretch starts at, how far the shares before it moved it, the last glyph + // the stretch keeps on course, and the shares (lowest, highest) that keep each since on it. + let (mut from, mut moved, mut last) = (0, 0.0, 0); + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for &(i, d) in &behind { + if i <= from { + continue; + } + let course = |from: usize, moved: f64| { + let n = (i - from) as f64; + ((d - moved - tolerance) / n, (d - moved + tolerance) / n) + }; + let (l, h) = course(from, moved); + if l > hi || h < lo { + // No share keeps this glyph on course too: the stretch ends at the last one it does. + let share = (lo + hi) / 2.0; + for e in own.get_mut(from..last).into_iter().flatten() { + *e += share; + } + moved += share * (last - from) as f64; + from = last; + (lo, hi) = course(from, moved); + } else { + (lo, hi) = (lo.max(l), hi.min(h)); + } + last = i; + } + // The last stretch takes its share after the characters past the last glyph the file placed + // too (it changes nothing there). + if lo.is_finite() && hi.is_finite() { + let share = (lo + hi) / 2.0; + for e in own.get_mut(from..).into_iter().flatten() { + *e += share; + } + } + own +} + /// The middle value of `v` (the upper one of an even count). pub(crate) fn median(mut v: Vec) -> Option { v.sort_by(f64::total_cmp); diff --git a/crates/pdf/src/tests_text_placement.rs b/crates/pdf/src/tests_text_placement.rs index 1b9e2a29c..fc0057a90 100644 --- a/crates/pdf/src/tests_text_placement.rs +++ b/crates/pdf/src/tests_text_placement.rs @@ -589,6 +589,40 @@ fn widths_rounded_off_the_installed_fonts_advances_keep_one_tracking() { assert!(t[0].runs[1].style.tracking > 500.0, "the em's gap on the space: {:?}", t[0].runs); } +/// A long line whose file widths are each a little off the font's advances (0.8% wider or +/// narrower, within the 1% that still counts as the same font) opens with its glyphs where the +/// file draws them, in one run: the differences add up along the line (with only the spacing the +/// file set, none, the glyphs would end points away from the file's), so the line takes their +/// share after every character. A line the font kerns (laid out without its kerning, as the file +/// sets it) and one it doesn't (laid out as the type is). Source Sans 3 is embedded: what the file +/// draws and what the type draws are the same outlines, so their contours tell where each glyph is. +#[test] +fn a_long_line_of_widths_a_little_off_the_fonts_advances_lands_in_one_run() { + let n = first_extra(1); + let resources = format!("/Font << /F1 {} 0 R >>", n + 2); + for scale in [1.008, 0.992] { + let widths: Vec = (32u8..=126).map(|c| (width(c as char) * scale).round().to_string()).collect(); + let font = format!( + "<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /FontDescriptor {} 0 R /Encoding /WinAnsiEncoding >>", + widths.join(" "), + n + 1 + ); + for line in [ + "Gather the quick brown fox over the lazy dogs while the waves roll in", + "minimum minimum minimum minimum minimum minimum minimum minimum", + ] { + let content = format!("BT /F1 12 Tf 10 50 Td ({line}) Tj ET"); + let page = PdfPage { resources: resources.clone(), ..PdfPage::new(600.0, 200.0, &content) }; + let bytes = pdf_with(&[page], &[&font_file(), &descriptor(n), &font], None); + let t = texts(&open_as(&bytes, TextAs::Text)); + assert_eq!(t.iter().map(TextObject::plain_text).collect::>(), [line]); + assert_eq!(t[0].runs.len(), 1, "{scale} {line:?}: {:?}", t[0].runs); + let d = displacement(&bytes); + assert!(d < 12.0 * 0.02, "{scale} {line:?}: glyphs {d:.3} pt from where the file draws them"); + } + } +} + /// 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 dd6f5d13b..ab70cd995 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,7 +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-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); where the widths are a little off the font's advances (up to the 1% that still counts as the same font) and the differences add up along a line, each stretch of the line takes their share after every character, so its glyphs stay within a hundredth of an em of the file's in a run per stretch (one on ordinary text), instead of a run per character, untracked type points off, or a line-wide fit tenths of a point off; tests with built-in Inter and embedded Source Sans 3, 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 |