mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
PDF import: letter-spaced text keeps its word spaces
PDF import read every gap wider than a fifth of an em between two glyphs as a space. Letter-spaced type (Tc, or glyphs placed one by one with tracking) has such a gap between every pair of letters, so "OPEN DAILY" came in as "O P E N D A I L Y". A line now records every gap between its glyphs and decides which are spaces when it finishes: the spacing its letters share (the gap a third of the way up from the narrowest, when the line has three or more) is taken off each gap first. That shared spacing is at most half an em, so glyphs set wider apart, such as figures in columns, keep their spaces. Right-to-left lines and glyphs along a curve read their gaps as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
398c6316d9
commit
52b63e2198
@@ -117,6 +117,32 @@ impl Look {
|
||||
}
|
||||
}
|
||||
|
||||
/// A gap between adjacent glyphs on a line.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Gap {
|
||||
/// Byte offset in the line's text of the glyph after the gap.
|
||||
at: usize,
|
||||
/// From the end of the glyph before to the start of the next (points).
|
||||
width: f64,
|
||||
/// The larger of the two glyphs' sizes (points).
|
||||
em: f64,
|
||||
/// Along the line, not turned along a curve.
|
||||
on_line: bool,
|
||||
}
|
||||
|
||||
/// The spacing a line's letters share (letter-spaced type), in ems: the gap a third of the way up
|
||||
/// from the narrowest, of three or more along the line. Never below zero, and at most half an em:
|
||||
/// glyphs set wider apart than that are words or columns, not tracked letters.
|
||||
fn common_gap(gaps: &[Gap]) -> f64 {
|
||||
let mut widths: Vec<f64> = gaps.iter().filter(|g| g.on_line).map(|g| g.width / g.em).filter(|w| w.is_finite()).collect();
|
||||
if widths.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let pivot = widths.len() / 3;
|
||||
let (_, common, _) = widths.select_nth_unstable_by(pivot, f64::total_cmp);
|
||||
common.clamp(0.0, 0.5)
|
||||
}
|
||||
|
||||
/// A line of type being gathered: runs of glyphs on one baseline.
|
||||
pub(crate) struct TextLine {
|
||||
/// The first glyph's placement.
|
||||
@@ -140,10 +166,13 @@ pub(crate) struct TextLine {
|
||||
/// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are
|
||||
/// left out.
|
||||
pub ink: Vec<Rect>,
|
||||
/// The spaces read from gaps between glyphs, and the spaces the file draws between two glyphs
|
||||
/// that aren't spaces: their byte offset in the text, and the gap from the end of the glyph
|
||||
/// before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// The gaps between adjacent glyphs that may read as spaces ([`finish`](Self::finish)
|
||||
/// decides).
|
||||
gaps: Vec<Gap>,
|
||||
/// The spaces the file draws between two glyphs that aren't spaces: their byte offset in the
|
||||
/// text before the spaces read from gaps go in, and the gaps on both sides of it plus its
|
||||
/// advance (points).
|
||||
drawn_spaces: Vec<(usize, f64)>,
|
||||
/// A space the file drew after a glyph that isn't one, until the next glyph comes: its byte
|
||||
/// offset, and the gap before it plus its advance (points).
|
||||
drawn_space: Option<(usize, f64)>,
|
||||
@@ -184,6 +213,7 @@ impl TextLine {
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
drawn_spaces: vec![],
|
||||
drawn_space: None,
|
||||
advances: vec![],
|
||||
held: vec![],
|
||||
@@ -266,12 +296,8 @@ impl TextLine {
|
||||
if !on_line && !on_curve {
|
||||
return false;
|
||||
}
|
||||
// A gap wider than a fifth of an em reads as a space.
|
||||
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
run.push(' ');
|
||||
if on_line {
|
||||
self.gaps.push((bytes, gap));
|
||||
}
|
||||
if !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
self.gaps.push(Gap { at: bytes, width: gap, em: 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();
|
||||
@@ -279,7 +305,7 @@ impl TextLine {
|
||||
if text == " " && !run.ends_with(' ') {
|
||||
self.drawn_space = Some((bytes, gap + advance));
|
||||
} else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(' ')) {
|
||||
self.gaps.push((b, before + gap));
|
||||
self.drawn_spaces.push((b, before + gap));
|
||||
}
|
||||
}
|
||||
if last.takes(look) {
|
||||
@@ -358,6 +384,35 @@ impl TextLine {
|
||||
let first = runs.next()?;
|
||||
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.
|
||||
let common = if t.para.direction.is_none() { common_gap(&self.gaps) } else { 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 {
|
||||
continue;
|
||||
}
|
||||
// After the earlier spaces, at the end of the run before it (the run it follows).
|
||||
let at = gap.at + spaces.len();
|
||||
let mut offset = 0;
|
||||
let Some(run) = t.runs.iter_mut().find(|run| {
|
||||
offset += run.text.len();
|
||||
at <= offset
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
let at = at - (offset - run.text.len());
|
||||
if !run.text.is_char_boundary(at) {
|
||||
continue;
|
||||
}
|
||||
run.text.insert(at, ' ');
|
||||
spaces.push((gap.at, gap.width, gap.on_line));
|
||||
}
|
||||
// Each by its offset in the text with the spaces in.
|
||||
let mut gaps: Vec<(usize, f64)> = spaces.iter().enumerate().filter(|(_, s)| s.2).map(|(i, s)| (s.0 + i, s.1)).collect();
|
||||
gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width)));
|
||||
gaps.sort_by_key(|&(b, _)| b);
|
||||
if self.upright.is_none() {
|
||||
to_logical(&mut t);
|
||||
}
|
||||
@@ -420,8 +475,7 @@ impl TextLine {
|
||||
// The spaces read from gaps or drawn between glyphs keep their own place only in text
|
||||
// left in the file's order.
|
||||
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
|
||||
let gaps: Vec<(usize, f64)> =
|
||||
if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
|
||||
let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
|
||||
let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size);
|
||||
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
|
||||
// Tracked: laid out again for its bounds.
|
||||
|
||||
@@ -309,6 +309,19 @@ fn text_becomes_point_type() {
|
||||
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_spacing_does_not_become_spaces() {
|
||||
for size in [12.0, 13.0, 14.0, 15.0] {
|
||||
for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] {
|
||||
let spacing = size * tracking / 1000.0;
|
||||
let content = format!("BT /F1 {size} Tf {spacing} Tc 10 40 Td (OPEN DAILY) Tj ET");
|
||||
let t = texts(&open(&one_page(&content, HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}");
|
||||
assert_eq!(t[0].plain_text(), "OPEN DAILY", "size {size}, tracking {tracking}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invisible text (rendering mode 3: a scanned page's recognized text, laid over the scan) is
|
||||
/// type with neither fill nor stroke, and the visible text keeps its paint. On a line with
|
||||
/// stroked text it is a run of its own, not stroked. As outlines it would draw nothing: it is left
|
||||
|
||||
@@ -101,6 +101,43 @@ fn letter_spacing_in_the_pdf_becomes_tracking() {
|
||||
assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_tracking_does_not_turn_gaps_into_spaces() {
|
||||
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
|
||||
for size in [12.0, 13.0, 14.0, 15.0] {
|
||||
for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] {
|
||||
let mut x = 20.0;
|
||||
let mut pieces = vec![];
|
||||
for (word_index, word) in ["OPEN", "DAILY", "TYPE"].iter().enumerate() {
|
||||
for character in word.chars() {
|
||||
pieces.push((x, character.to_string()));
|
||||
x += font.advance(font.glyph_for(character)) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0;
|
||||
}
|
||||
if word_index < 2 {
|
||||
x += font.advance(font.glyph_for(' ')) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0;
|
||||
}
|
||||
}
|
||||
let lines: Vec<_> = pieces.iter().map(|(x, text)| (*x, 60.0, size, text.as_str())).collect();
|
||||
let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document;
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}");
|
||||
assert_eq!(t[0].plain_text(), "OPEN DAILY TYPE", "size {size}, tracking {tracking}");
|
||||
assert!((t[0].first_style().tracking - f64::from(tracking)).abs() <= 10.0, "size {size}, tracking {tracking}: {:?}", t[0].runs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyphs set an em or more apart, each on its own, are words or columns: every gap stays a space,
|
||||
/// however evenly they are spaced.
|
||||
#[test]
|
||||
fn evenly_wide_gaps_stay_spaces() {
|
||||
let lines: Vec<_> = ["1", "2", "3", "4"].iter().enumerate().map(|(i, text)| (20.0 + i as f32 * 18.0, 60.0, 12.0, *text)).collect();
|
||||
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(), "1 2 3 4");
|
||||
}
|
||||
|
||||
/// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic.
|
||||
#[test]
|
||||
fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user