mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
1bb3e916fe
commit
32abf71049
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user