From bba3537ea3efa1cbfc7ea0e28f56a1c97dbf2385 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:30:53 -0700 Subject: [PATCH] Part of #2384: imported PDF text keeps its place beside a drawn no-break or thin space A space the file draws between two words gets tracking of its own so the word after it stays where the file put it, but only U+0020 counted as a space. A drawn no-break space (U+00A0, U+202F) or fixed-width space (U+2000 to U+200A, thin and hair spaces included) took the old path: the gaps on both sides of it also read as spaces, so the line came in with an extra space before and after it and the words after it out of place. These characters now count as spaces in the line's gap and drawn-space rules, and a space of more than one byte is split into its own run whole. The wrapped-line space tolerance in area type, a fiftieth of an em, is named SPACE_TRACKING_TOLERANCE. --- crates/pdf/src/import_lines.rs | 6 +++++- crates/pdf/src/import_text.rs | 20 ++++++++++++++------ crates/pdf/src/tests_import_lines.rs | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs index d29b354e7..b880c74ab 100644 --- a/crates/pdf/src/import_lines.rs +++ b/crates/pdf/src/import_lines.rs @@ -153,6 +153,10 @@ fn block(nodes: &[Arc], lines: &HashMap) -> (usize, Opt /// the file's widths are rounded, or it moved a glyph a hair (`TJ`). const EVEN_TRACKING: f64 = 10.0; +/// How far the tracking of a wrapped line's spaces may stray (thousandths of an em, a fiftieth of +/// an em) and still be one value: each space the file draws beside a wider gap is tracked apart. +const SPACE_TRACKING_TOLERANCE: f64 = 20.0; + /// A line's spacing: the tracking after its letters, and how much more its spaces take (word /// spacing, `Tw`; `None` when it has none). `None` when its letters are tracked apart (set by /// hand), or its spaces more than `slack` apart. @@ -194,7 +198,7 @@ fn area_type(nodes: &[Arc], lines: &HashMap, b: &Block) // type's own. // A wrapped line's spaces may each be tracked apart, within a fiftieth of an em of each other. let spacings: Vec<(f64, Option)> = - texts.iter().map(|(t, f)| line_spacing(t, if f.wraps { 20.0 } else { EVEN_TRACKING })).collect::>()?; + texts.iter().map(|(t, f)| line_spacing(t, if f.wraps { SPACE_TRACKING_TOLERANCE } else { EVEN_TRACKING })).collect::>()?; let trackings: Vec = spacings.iter().map(|(t, _)| *t).collect(); let words: Vec> = spacings.iter().map(|(_, w)| *w).collect(); let wrapped: Vec = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect(); diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 4eca5d11a..dddb0075e 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -276,7 +276,7 @@ impl TextLine { self.column = true; // Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left // out (the tracking set in `finish` keeps narrower gaps). - if step > size * 0.9 && !run.ends_with(' ') && !text.starts_with(' ') { + if step > size * 0.9 && !run.ends_with(is_space) && !text.starts_with(is_space) { run.push(' '); } if last.takes(look) { @@ -334,15 +334,16 @@ impl TextLine { if !on_line && !on_curve { return false; } - if !run.ends_with(' ') && !text.starts_with(' ') { + if !run.ends_with(is_space) && !text.starts_with(is_space) { self.gaps.push(Gap { at: bytes, width: gap, em: self.last_size.min(at.size), on_line }); } // A space the file draws between two glyphs spans the gaps on both sides of it. let drawn = self.drawn_space.take(); if on_line { - if text == " " && !run.ends_with(' ') { + let mut chars = text.chars(); + if chars.next().is_some_and(is_space) && chars.next().is_none() && !run.ends_with(is_space) { self.drawn_space = Some((bytes, gap + advance)); - } else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(' ')) { + } else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(is_space)) { self.drawn_spaces.push((b, before + gap)); } } @@ -904,12 +905,13 @@ fn tracked(runs: Vec, tracking: f64, spaces: &[(usize, f64)]) -> Vec, tracking: f64, spaces: &[(usize, f64)]) -> Vec bool { + matches!(c, ' ' | '\u{a0}' | '\u{2000}'..='\u{200a}' | '\u{202f}') +} + fn round(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 } diff --git a/crates/pdf/src/tests_import_lines.rs b/crates/pdf/src/tests_import_lines.rs index 4e81666c0..ebb128857 100644 --- a/crates/pdf/src/tests_import_lines.rs +++ b/crates/pdf/src/tests_import_lines.rs @@ -226,6 +226,21 @@ fn words_set_ems_apart_beside_the_spaces_the_file_draws_keep_their_place() { } } +#[test] +fn words_set_ems_apart_beside_a_drawn_no_break_or_thin_space_keep_their_place() { + // As above, the file drawing a no-break space and a thin space alone, with a gap on each side. + let (x1, x2) = (48.0, 84.0); + let lines: &[(f32, f32, f32, &str)] = + &[(20.0, 50.0, 10.0, "ab"), (36.0, 50.0, 10.0, "\u{a0}"), (x1, 50.0, 10.0, "cd"), (70.0, 50.0, 10.0, "\u{2009}"), (x2, 50.0, 10.0, "ef")]; + let t = texts(&import(&source_sans(lines)).unwrap()); + assert_eq!(t.len(), 1); + assert_eq!(t[0].plain_text(), "ab\u{a0}cd\u{2009}ef"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]); + let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x; + assert!((at(4) - f64::from(x1)).abs() < 0.05, "the second word starts where the file has it: {} vs {x1}", at(4)); + assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third word starts where the file has it: {} vs {x2}", at(9)); +} + /// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30). fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document { let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() };