collective

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

diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs--- a/crates/pdf/src/import_text.rs+++ b/crates/pdf/src/import_text.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 {diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs--- a/crates/pdf/src/tests_live_text.rs+++ b/crates/pdf/src/tests_live_text.rs@@ -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();diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs--- a/crates/pdf/src/tests_realtext.rs+++ b/crates/pdf/src/tests_realtext.rs@@ -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:?}");