fix(text): Add word boundary detection for CID font missing spaces

Multi-character words from separate Tj operators (CID/Identity-H fonts)
were being joined without spaces when positioned back-to-back (gap≈0).
Detect multi-char items touching at near-zero gap and insert spaces,
while preserving single-char per-glyph joining for running headers.

Also strip null bytes and control characters in expand_ligatures() to
clean Latin-1 fallback artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-12 16:30:02 -08:00
co-authored by Claude Opus 4.6
parent f94be6093c
commit a08fb61cc5
+123 -1
View File
@@ -678,6 +678,16 @@ fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool {
let prev_end_x = prev_item.x + prev_item.width; let prev_end_x = prev_item.x + prev_item.width;
let gap = curr_item.x - prev_end_x; let gap = curr_item.x - prev_end_x;
let font_size = prev_item.font_size; let font_size = prev_item.font_size;
// When items perfectly touch (gap ≈ 0) and both are multi-character,
// they are likely separate words from different text operators.
// Single-character items (per-glyph positioning) should still be joined.
let prev_chars = prev_item.text.trim().chars().count();
let curr_chars = curr_item.text.trim().chars().count();
if gap >= 0.0 && gap < font_size * 0.05 && prev_chars >= 3 && curr_chars >= 2 {
return false; // Don't join — separate words
}
// With accurate widths, a gap < 15% of font size means glyphs are // With accurate widths, a gap < 15% of font size means glyphs are
// adjacent (same word). Anything larger is a deliberate space. // adjacent (same word). Anything larger is a deliberate space.
return gap < font_size * 0.15; return gap < font_size * 0.15;
@@ -1841,13 +1851,25 @@ fn extract_text_from_operand(
/// Expand Unicode ligature characters to their component characters. /// Expand Unicode ligature characters to their component characters.
/// This makes extracted text more searchable and semantically correct. /// This makes extracted text more searchable and semantically correct.
fn expand_ligatures(text: &str) -> String { fn expand_ligatures(text: &str) -> String {
// Strip null bytes and other control characters (except newline/tab)
let text = if text
.bytes()
.any(|b| b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t')
{
text.chars()
.filter(|&c| c >= ' ' || c == '\n' || c == '\r' || c == '\t')
.collect::<String>()
} else {
text.to_string()
};
if !text.contains('\u{FB00}') if !text.contains('\u{FB00}')
&& !text.contains('\u{FB01}') && !text.contains('\u{FB01}')
&& !text.contains('\u{FB02}') && !text.contains('\u{FB02}')
&& !text.contains('\u{FB03}') && !text.contains('\u{FB03}')
&& !text.contains('\u{FB04}') && !text.contains('\u{FB04}')
{ {
return text.to_string(); return text;
} }
text.replace('\u{FB00}', "ff") text.replace('\u{FB00}', "ff")
.replace('\u{FB01}', "fi") .replace('\u{FB01}', "fi")
@@ -2387,4 +2409,104 @@ mod tests {
assert!(is_bold_font("Helvetica-BoldOblique")); assert!(is_bold_font("Helvetica-BoldOblique"));
assert!(is_italic_font("Helvetica-BoldOblique")); assert!(is_italic_font("Helvetica-BoldOblique"));
} }
#[test]
fn test_word_level_items_get_spaces() {
// Simulate CID font per-word items touching with gap=0
let items = vec![
TextItem {
text: "the".into(),
x: 100.0,
y: 500.0,
width: 19.5,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "Prague".into(),
x: 119.5,
y: 500.0,
width: 42.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "Rules".into(),
x: 161.5,
y: 500.0,
width: 35.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
];
let lines = group_into_lines(items);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text(), "the Prague Rules");
}
#[test]
fn test_single_char_items_still_join() {
// Per-glyph positioning: single chars should join into words
let items = vec![
TextItem {
text: "N".into(),
x: 100.0,
y: 500.0,
width: 8.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "A".into(),
x: 108.0,
y: 500.0,
width: 8.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "V".into(),
x: 116.0,
y: 500.0,
width: 8.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
];
let lines = group_into_lines(items);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text(), "NAV");
}
} }