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:
co-authored by
Claude Opus 4.6
parent
a199768c4e
commit
8c4181434f
+115
-4
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user