M7: keep a word on its line when its spacing differs by less than a thousandth of an em

Joining runs into one line compared their character and word spacing to
1e-6. Word documents exported through macOS Quartz write 0.0001 Tc on
some words, so a line like "for company AEROSOL" split into "for
company A" and "EROSOL", two paragraphs that edit separately.

Spacing below a thousandth of an em, the unit glyph widths are given in,
can't be seen. Compare character and word spacing to that; scale and
rise still have to match exactly, and visible spacing still keeps runs
apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
komo1iddin
2026-10-21 22:41:17 +08:00
co-authored by Claude Opus 5.5
parent 4164951453
commit 475f375ff4
2 changed files with 31 additions and 2 deletions
+24
View File
@@ -5218,3 +5218,27 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
/// A producer wrote `0.0001 Tc` on one word of a line (Word through macOS Quartz). That spacing
/// can't be seen, so the word stays on its line; spacing that can be seen still keeps runs apart.
#[test]
fn a_word_with_invisible_extra_spacing_stays_on_its_line() {
let page = |spacing: &str| {
text_page(&format!(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (for company ) Tj ET \
BT /F1 12 Tf 1 0 0 1 141.36 700 Tm (A) Tj ET \
BT {spacing} Tc /F1 12 Tf 1 0 0 1 149.364 700 Tm (EROSOL) Tj ET"
))
};
let blocks = text::text_blocks(&page("0.0001"), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["for company AEROSOL"]);
let mut doc = page("0.0001");
text::rewrite_block(&mut doc, 0, 0, Some("for company AEROSOL LLC"), &BlockStyle::default()).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["for company AEROSOL LLC"]);
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{:?}", lines[0].rect);
let blocks = text::text_blocks(&page("0.5"), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["for company A", "EROSOL"]);
}
+7 -2
View File
@@ -1203,12 +1203,17 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
/// Joining independent text objects is safe only when they use the same text and graphics
/// transforms and spacing. In particular, a nearby rotated label isn't part of this line.
fn compatible_word(line: &TextLine, shown: &Shown) -> bool {
let same = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6);
let within = |a: &[f64], b: &[f64], tolerance: f64| a.iter().zip(b).all(|(a, b)| (a - b).abs() < tolerance);
let same = |a: &[f64], b: &[f64]| within(a, b, 1e-6);
let (a, b) = (&line.origin.state, TextState::of(&shown.state));
// Spacing that differs by less than a thousandth of an em, the unit glyph widths are given in,
// can't be seen. Some producers still write it (`0.0001 Tc` on one word of a line).
let em = shown.state.size.abs() / 1000.0;
line.scope == shown.scope
&& same(&line.origin.tm[..4], &shown.tm.0[..4])
&& same(&line.origin.ctm, &shown.state.ctm.0)
&& same(&[a.char_spacing, a.word_spacing, a.scale, a.rise], &[b.char_spacing, b.word_spacing, b.scale, b.rise])
&& within(&[a.char_spacing, a.word_spacing], &[b.char_spacing, b.word_spacing], em.max(1e-6))
&& same(&[a.scale, a.rise], &[b.scale, b.rise])
&& a.fill.len() == b.fill.len()
&& a.fill.iter().zip(&b.fill).all(|(a, b)| a.op == b.op && a.operands == b.operands)
}