Merge pull request #3753 from Taha-Mahmoodi/fix/pdf-import-drift

PDF import: keep a line's glyphs on course when the file's widths are a little off the font's advances
This commit is contained in:
BFlat
2026-10-23 10:42:59 +07:00
committed by GitHub
3 changed files with 100 additions and 8 deletions
+64 -6
View File
@@ -623,7 +623,8 @@ fn restyled(t: &TextObject, kern: Option<f64>, own: &[f64]) -> Vec<TextRun> {
/// 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<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(&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<f64>, pens: &[Option<(f64, f64)>], target: &[Option<(f64, Option<f64>)>], size: f64) -> Vec<f64> {
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<f64>) -> Option<f64> {
v.sort_by(f64::total_cmp);
+34
View File
@@ -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<String> = (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::<Vec<_>>(), [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<u8> {
File diff suppressed because one or more lines are too long