mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
PDF import: keep a line's glyphs on course when the file's widths are a little off the font's advances
Cause: since #3742 place_each measures a line's spacing from the file's own widths, so tracking is only the file's Tc/Tw/TJ. Where the widths are systematically off the installed font's advances (up to the 1% that still counts as the same font), those differences add up along a line and nothing made up for them. With no kerning and every tracking value 0, place_each returned the untracked layout early, without the 0.02 em placement check (CodeRabbit's review of #3742): a line of embedded Source Sans 3 with /Widths 0.8% wide opened 3.7 pt off. With the early return removed the check rejects the line and the line-wide fit takes over: one whole-thousandth tracking value with the font's kerning, 0.30 pt off (as before #3715). Before #3742 main placed it exactly but with a run per character (49 and 62 runs). Change: the early return is gone; every line goes through the check. Before it, on_course() keeps the file's spacing where the glyphs stay within 0.01 em of the file's; otherwise each stretch of the line takes one share after every character (the longest stretch one share keeps within 0.01 em), so ordinary text stays one run, and a line whose differences change course (30 m then 30 i) takes a run per stretch (2). Verification: new test a_long_line_of_widths_a_little_off_the_fonts_advances_lands_in_one_run (embedded Source Sans 3, widths x1.008 and x0.992, a kerned and an unkerned line): one run each, glyphs under 0.24 pt (0.02 em) from where the file draws them. Without on_course it fails after #3742, with and without the early return ("glyphs 0.300 pt"); before #3742 it fails on the run count. It replaces an Inter test drafted for the review: Inter isn't embedded there, so the file's outlines are the interpreter's stand-in stretched to the widths, not Inter, and the contours differ by 0.6 pt even with exact widths. tests_text_placement and text_becomes_point_type pass; cargo xtask ci: all 10 steps passed. Thanks to the CodeRabbit review on #3742 for the early-return finding.
This commit is contained in:
committed by
bflatastic
parent
ccec72a82d
commit
b6b1ec7300
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user