From 73cbba8b8d6d89e2313530d0ee3c467265ebd6ba Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:45:00 -0600 Subject: [PATCH] PDF import: even gaps alone still read as word spaces Glyphs placed one by one with even gaps could be letters set apart or words of one letter each, and a file's positions can't tell them apart. The spacing a line's letters share is now taken off its gaps only when the line also shows where its words break: with spaces it draws (as with Tc), or with gaps wider than the rest (tracked words placed glyph by glyph). Otherwise every gap past a fifth of an em is a space, as before, so A, B, C and D placed with ordinary word spaces between them still come in as "A B C D". Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import_text.rs | 15 +++++++++++---- crates/pdf/src/tests_live_text.rs | 20 ++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 062d202c9..764712a35 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -385,13 +385,20 @@ impl TextLine { let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); // A gap wider than a fifth of an em, past the spacing the line's letters share, reads as a - // space. Letter spacing is read only in text left in the file's order. + // 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 common = if directed { 0.0 } else { common_gap(&self.gaps) }; + let mut common = if directed { 0.0 } else { 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. + if self.drawn_spaces.is_empty() && !self.gaps.iter().any(|gap| space(gap, common)) { + common = 0.0; + } let mut spaces = vec![]; for gap in &self.gaps { - // A word space set at exactly a fifth of an em still counts, whatever the rounding. - if gap.width / gap.em - common <= 0.199 { + if !space(gap, common) { continue; } // After the earlier spaces, at the end of the run before it (the run it follows). diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index cf61b6179..4b1d2a56f 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -127,6 +127,26 @@ fn common_tracking_does_not_turn_gaps_into_spaces() { } } +/// Words of one letter each, placed one by one with ordinary word spaces between them, are words: +/// even gaps alone don't read as letter spacing. +#[test] +fn evenly_spaced_one_letter_words_keep_their_spaces() { + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + let size = 12.0_f32; + for gap in [0.25_f32, 0.3, 0.35] { + let mut x = 20.0; + let mut lines = vec![]; + for text in ["A", "B", "C", "D"] { + lines.push((x, 60.0, size, text)); + x += font.advance(font.glyph_for(text.chars().next().unwrap())) as f32 / font.units_per_em() as f32 * size + gap * size; + } + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "gap {gap} em: {t:?}"); + assert_eq!(t[0].plain_text(), "A B C D", "gap {gap} em"); + } +} + /// Glyphs set more than half an em apart, each on its own, are words or columns: every gap stays a /// space, however evenly they are spaced. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index afea6acc5..ee6d0bd86 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -784,7 +784,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. In imported PDF type, a space glyph the file draws between two words also keeps the next word where the file has it when the gap beside the space is wide, as VectorCraft's own export draws a tracked space: the space gets tracking of its own, and the wrapped lines of a justified paragraph still come back as one area type object when the word gaps on each line are alike (part of #2384). - Letter-spaced PDF type, whether set with character spacing (`Tc`) or glyph by glyph, keeps its word spaces and gains none between its letters: the spacing a line's letters share is taken off each gap before a gap reads as a space. Glyphs set more than half an em apart, such as figures in columns, keep their spaces. + Letter-spaced PDF type, whether set with character spacing (`Tc`) or glyph by glyph, keeps its word spaces and gains none between its letters: the spacing a line's letters share is taken off each gap before a gap reads as a space. That holds on a line that also shows where its words break, with spaces the file draws or gaps wider than the rest; evenly spaced glyphs alone, which could be words of one letter, keep every gap as a space, as do glyphs set more than half an em apart, such as figures in columns. SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `