Text: tracked glyphs retain their word boundaries (#3477)

* Text: tracked glyphs retain their word boundaries

* M7: cap inferred tracking at half an em so one-glyph words and cells keep their spaces

Adds the regression test from the #3477 review (fails without the cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
KnottyDyes
2026-10-19 12:57:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 1bb3e916fe
commit 32abf71049
4 changed files with 170 additions and 7 deletions
+86
View File
@@ -904,6 +904,92 @@ fn text_page(content: &str) -> Document {
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn tracked_text_keeps_words_in_paragraphs_and_objects() {
let expected = "TRACKED SAMPLE TEXT";
for spacing in [1.5, 2.0001, 3.0] {
for scale in [50.0, 100.0, 150.0] {
for positioned in [false, true] {
let mut show = String::new();
if !positioned {
show.push('[');
}
for (i, c) in expected.chars().enumerate() {
if positioned {
show.push_str(&format!("({c}) Tj "));
if i + 1 < expected.len() {
show.push_str(&format!("[{}] TJ ", -spacing * 100.0));
}
} else {
show.push_str(&format!("({c}) "));
if i + 1 < expected.len() {
show.push_str(&format!("{} ", -spacing * 100.0));
}
}
}
if !positioned {
show.push_str("] TJ");
}
let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET"));
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
}
}
let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
}
}
#[test]
fn single_glyph_words_and_cells_are_not_tracking() {
for (content, expected) in [
("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"),
("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"),
("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"),
] {
assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}");
}
let mut s = String::from("BT /F1 10 Tf ");
for (i, c) in "12345".chars().enumerate() {
s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12));
}
s.push_str("ET");
assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5");
}
#[test]
fn tracked_text_preserves_larger_word_gaps() {
for show in [
"[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ",
"(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
"[(TEST) -300 (WORD)] TJ",
"(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
] {
let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}");
}
}
#[test]
fn tracked_text_positions_do_not_count_character_spacing_twice() {
let expected = "TRACKED SAMPLE TEXT";
for char_spacing in [0.0, 2.0] {
for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] {
let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc ");
let mut x = 72.0;
for c in expected.chars() {
content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj "));
x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001;
}
content.push_str("ET Q");
let doc = text_page(&content);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
}
}
}
fn descriptor_text_page() -> Document {
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
let objs: Vec<String> = vec![
+82 -6
View File
@@ -635,6 +635,7 @@ struct Shown {
glyph_count: usize,
/// Advance in text space, including spacing and TJ adjustments.
advance: f64,
tracking: f64,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
tm: Matrix,
tlm: Matrix,
@@ -702,6 +703,77 @@ impl Pen {
}
}
/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not
/// evidence of tracking; unadjusted neighbours inside a string contribute zero too.
/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at
/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph
/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces.
fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 {
if gaps.len() < 3 {
return 0.0;
}
let at = gaps.len() / 3;
gaps.select_nth_unstable_by(at, f64::total_cmp);
gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0)
}
fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 {
let mut gaps = Vec::new();
let mut previous = false;
let mut gap = 0.0;
for piece in pieces {
if let Object::String(s) = piece {
for (code, len) in metrics.codes(&s.bytes) {
let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
if previous && !blank {
gaps.push(gap);
}
previous = !blank;
gap = 0.0;
}
} else if let Some(n) = piece.as_f64() {
gap += -n / 1000.0 * size * scale;
}
}
typical_tracking(&mut gaps, size * scale.abs() * 0.5)
}
fn positioned_tracking(shown: &mut [Shown]) {
let mut start = 0;
while start < shown.len() {
let mut end = start.saturating_add(1);
while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) {
if prev.glyph_count != 1
|| next.glyph_count != 1
|| prev.font != next.font
|| prev.scope != next.scope
|| (prev.size - next.size).abs() > 0.01
|| prev.state.char_spacing != next.state.char_spacing
|| prev.state.scale != next.state.scale
|| !Pen::after(prev).continues(next, 1.5).0
{
break;
}
end = end.saturating_add(1);
}
if let Some(run) = shown.get_mut(start..end) {
let mut gaps: Vec<f64> = run
.windows(2)
.filter_map(|pair| {
let [prev, next] = pair else { return None };
(!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1)
})
.collect();
let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5);
let tracking = typical_tracking(&mut gaps, limit);
for s in run.iter_mut().skip(1) {
s.tracking = tracking;
}
}
start = end;
}
}
/// The graphics state carried from one of a page's content streams to the next: the streams
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
@@ -883,6 +955,8 @@ fn interpret(
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
@@ -924,12 +998,12 @@ fn interpret(
if let Some(n) = other.as_f64() {
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx > ts.size * 0.2 && !text.ends_with(' ') {
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Kerning keeps a word together; a jump either way of more
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
@@ -976,6 +1050,7 @@ fn interpret(
extra_ops: Vec::new(),
glyph_count,
advance: x_text,
tracking: 0.0,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
state: ts.clone(),
tlm,
@@ -1009,6 +1084,7 @@ fn interpret(
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
positioned_tracking(&mut out);
out
}
@@ -1057,7 +1133,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
});
if joins && let Some(l) = lines.last_mut() {
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);
@@ -1147,7 +1223,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
});
if joins && let Some(l) = lines.last_mut() {
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);