fix(text): handle Tc/Tw character and word spacing (#11)

* fix(text): handle Tc/Tw character and word spacing in text width computation

PDFs using Tc (character spacing) and Tw (word spacing) operators for text
justification had words incorrectly split across TextItems. The computed
advance width didn't account for these spacing parameters, causing spurious
spaces mid-word (e.g. "deve lopers" instead of "developers").

- Add Tc/Tw operator handling and graphics state save/restore
- Incorporate char_spacing and word_spacing into compute_string_width_ts
- Add adaptive merge threshold: tighter for lowercase→lowercase junctions,
  wider before joining punctuation
- Add unit tests for Tc/Tw width computation and merge behavior

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

* test(fonts): add large Tc width computation test

Verifies that large character spacing values are applied in full
without any artificial cap, matching PDF spec behavior.

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

* fix(text): guard against Tc/Tw-inflated widths in merge and join paths

Two targeted fixes to prevent character-spacing (Tc) and word-spacing
(Tw) inflation from causing data quality regressions:

1. should_join_items: reject large negative gaps (< -font_size) that
   arise when Tc/Tw inflate item widths past adjacent items. Fixes
   FY_2015 merged numbers (e.g. "239.696.0" → "239.69 6.0").

2. merge_text_items: cap effective width for gap computation when Tw
   inflates space-containing items beyond 0.85× font_size per char.
   Prevents column-level gaps from collapsing into merge range,
   recovering table detection for Baldwin-Edwards and similar PDFs.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-23 17:30:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a199768c4e
commit 8c4181434f
8 changed files with 292 additions and 42 deletions
+115 -4
View File
@@ -272,6 +272,44 @@ pub(crate) fn multiply_matrices(m1: &[f32; 6], m2: &[f32; 6]) -> [f32; 6] {
/// Groups items by (page, Y-position) with a 5pt tolerance, sorts within each
/// group by X, then merges consecutive items that share a similar font size
/// and are close horizontally.
/// Cap item width for merge-gap computation to guard against Tw inflation.
///
/// When PDF word-spacing (Tw) is large (used for text justification), the
/// advance width of strings containing spaces extends far past the visible
/// glyph extent. This inflated width collapses inter-column gaps, making
/// `merge_text_items` incorrectly merge items from different table columns.
///
/// Only applies to non-CJK items whose text contains spaces (where Tw
/// contributes) and whose average width-per-character is abnormally high.
fn effective_merge_width(item: &TextItem) -> f32 {
use crate::text_utils::is_cjk_char;
if item.width <= 0.0 || item.font_size <= 0.0 {
return item.width;
}
// Tw only inflates strings that contain space characters.
if !item.text.contains(' ') {
return item.width;
}
// CJK characters are naturally ~1.0× font_size wide; skip the cap.
if item.text.chars().any(is_cjk_char) {
return item.width;
}
let char_count = item.text.chars().count();
if char_count == 0 {
return item.width;
}
let avg = item.width / char_count as f32;
// Normal proportional text: ~0.5× font_size per char.
// Monospace: ~0.6×. Threshold at 0.85× catches Tw inflation.
if avg > item.font_size * 0.85 {
let capped = char_count as f32 * item.font_size * 0.6;
capped.min(item.width)
} else {
item.width
}
}
pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
if items.is_empty() {
return items;
@@ -315,7 +353,7 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
while i < group.len() {
let first = group[i];
let mut text = first.text.clone();
let mut end_x = first.x + first.width;
let mut end_x = first.x + effective_merge_width(first);
let x_gap_max = first.font_size * 0.5;
let mut j = i + 1;
@@ -332,12 +370,30 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
if gap < -first.font_size * 0.5 {
break;
}
// Insert space at word boundaries
if gap > first.font_size * 0.08 {
// Insert space at word boundaries.
// Base threshold 0.08; raised to 0.13 for lowercase→lowercase
// junctions to accommodate Tc/Tw character-spacing adjustments
// that shift advance widths relative to Td positioning.
let threshold = {
let prev_last = text.trim_end().chars().last();
let next_first = next.text.trim_start().chars().next();
// Never insert space before joining punctuation
if next_first.is_some_and(|c| matches!(c, '.' | ',' | ';' | ')' | ']' | '}')) {
first.font_size * 0.25
} else if prev_last.is_some_and(|c| c.is_lowercase())
&& next_first.is_some_and(|c| c.is_lowercase())
{
// Lowercase→lowercase: likely mid-word, use wider threshold
first.font_size * 0.13
} else {
first.font_size * 0.08
}
};
if gap > threshold {
text.push(' ');
}
text.push_str(&next.text);
end_x = next.x + next.width;
end_x = next.x + effective_merge_width(next);
j += 1;
}
@@ -465,6 +521,61 @@ mod tests {
use crate::types::{ItemType, TextLine};
use layout::{detect_columns, is_newspaper_layout, ColumnRegion};
fn make_merge_item(text: &str, x: f32, width: f32) -> TextItem {
TextItem {
text: text.into(),
x,
y: 700.0,
width,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn merge_items_no_space_before_period() {
// Simulate Tc/Tw-adjusted width: "date" width is smaller than the gap
// to "." due to negative Tc, but period should still join without space.
let items = vec![
make_merge_item("date", 227.25, 89.25), // end = 316.50
make_merge_item(".", 318.00, 3.0), // gap = 1.50 (0.125 × fs)
];
let merged = merge_text_items(items);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].text, "date.");
}
#[test]
fn merge_items_lowercase_join_with_tc() {
// Lowercase→lowercase junction: "deve" + "lopers" with Tc-affected gap
// Gap of 0.12 × font_size should merge without space
let items = vec![
make_merge_item("deve", 100.0, 30.0), // end = 130.0
make_merge_item("lopers", 131.44, 40.0), // gap = 1.44 (0.12 × 12)
];
let merged = merge_text_items(items);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].text, "developers");
}
#[test]
fn merge_items_space_at_word_boundary() {
// Word boundary gap (> 0.13 × font_size) should insert space
let items = vec![
make_merge_item("hello", 100.0, 30.0),
make_merge_item("world", 132.0, 30.0), // gap = 2.0 (0.167 × 12)
];
let merged = merge_text_items(items);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].text, "hello world");
}
#[test]
fn test_group_into_lines() {
let items = vec![