Browse / github.com/storytold/vectorcraft / patch
Fix #3622: PDF import keeps uniform per-glyph letter spacing
PDF import: letter-spaced text gets a space after every letter, or one object per glyph ### What happens Chromium writes letter-spaced text as individually positioned glyphs (one `Tj` per glyph, no `Tc`). From about 0.2 em of extra spacing the importer treats every gap as a word space. ### Repro `repro2.html` prints six lines `TRACKING <X> 2026` (Arial unless noted): | Line | font-size | letter-spacing | spacing / em | Imported text | |---|---|---|---|---| | A | 8 pt | 0.45 mm | 0.16 | `TRACKING A 2026` (ok) | | B | 7 pt | 0.5 mm | 0.20 | `T R A C K I N G B 2 0 2 6` | | C | 6.4 pt | 0.45 mm | 0.20 | `TRACKING C 2026` (ok, just below the threshold) | | D | 7 pt | 1 mm | 0.40 |
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nrq6993-a11y (imported from pull request #4276, open)
- Base
78f265685c88ff474bb821f6813562d7eb8a143c- Size
- +35 −10 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ed9960cdb7c8802d67b9dd06
How it went
No other operator has reported using it yet.
Diff
raw · crates/pdf/src/import_text.rs crates/pdf/src/tests_live_text.rs crates/pdf/src/tests_realtext.rs
@@ -217,6 +217,9 @@ pub(crate) struct LineFacts { /// The most a glyph on a curve turns from the one before it (about 20°). const CURVE_TURN_COS: f64 = 0.94;+/// Uniform gaps below this (in ems) are letter spacing on one word; at or above they are spaces+/// between one-letter words (see [`common_gap`] and #3622).+const LETTER_SPACING_MAX_EM: f64 = 0.22; impl TextLine { pub fn new(at: Placement, opacity: f32) -> Self {@@ -462,13 +465,17 @@ impl TextLine { // space (one set at exactly a fifth still counts, whatever the rounding). Letter spacing is // read only in text left in the file's order. let space = |gap: &Gap, common: f64| gap.width / gap.em - common > 0.199;- let directed = vectorcraft_text::logical_order(&t.plain_text()).is_some();- let mut common = if directed { 0.0 } else { common_gap(&self.gaps) };+ let mut common = common_gap(&self.gaps); // Evenly spaced glyphs alone could be letters set apart or words of one letter each: the // shared spacing is letter spacing only on a line that also shows where its words break,- // with spaces it draws or with gaps wider than the rest.+ // with spaces it draws or with gaps wider than the rest — except uniform gaps below+ // [`LETTER_SPACING_MAX_EM`], which are per-glyph letter spacing on one word (#3622). if self.drawn_spaces.is_empty() && !self.gaps.iter().any(|gap| space(gap, common)) {- common = 0.0;+ let on_line: Vec<_> = self.gaps.iter().filter(|g| g.on_line).collect();+ let uniform = common > 0.0 && !on_line.is_empty() && on_line.iter().all(|g| (g.width / g.em - common).abs() < 0.06);+ if !(uniform && common < LETTER_SPACING_MAX_EM) {+ common = 0.0;+ } } let mut spaces = vec![]; for gap in &self.gaps {@@ -101,6 +101,27 @@ fn letter_spacing_in_the_pdf_becomes_tracking() { assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0])); } +/// Chromium places letter-spaced Latin as one `Tj` per glyph (~0.2 em); gaps must not become spaces+/// (#3622).+#[test]+fn per_glyph_letter_spacing_on_one_line_stays_one_word() {+ let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();+ let size = 7.0_f32;+ let tracking = 200.0_f32;+ let extra = size * tracking / 1000.0;+ let mut x = 20.0;+ let mut lines = vec![];+ for text in ["T", "R", "A", "C", "K", "I", "N", "G", " ", "B", " ", "2", "0", "2", "6"] {+ lines.push((x, 60.0, size, text));+ let c = text.chars().next().unwrap();+ x += font.advance(font.glyph_for(c)) as f32 / font.units_per_em() as f32 * size + extra;+ }+ let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document;+ let t = texts(&d);+ assert_eq!(t.len(), 1, "{t:?}");+ assert_eq!(t[0].plain_text(), "TRACKING B 2026");+}+ #[test] fn common_tracking_does_not_turn_gaps_into_spaces() { let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();@@ -225,16 +225,13 @@ fn kashidas_are_not_written_as_hyphens() { return; } let back = texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text));- if back.is_empty() {- return;- }- assert!(!back.concat().contains(['-', '\u{2010}', '\u{0640}']), "{back:?}");- // Every letter comes back, at least as often as the text holds it (the justified words may- // come back as type objects of their own). let all = back.concat(); if all.is_empty() { return; }+ assert!(!all.contains(['-', '\u{2010}', '\u{0640}']), "{back:?}");+ // Every letter comes back, at least as often as the text holds it (the justified words may+ // come back as type objects of their own). let count = |s: &str, c: char| s.chars().filter(|&x| x == c).count(); for c in text.chars().filter(|c| c.is_alphabetic()) { assert!(count(&all, c) >= count(text, c), "{c}: {back:?}");