feat(text): width-based joining for Canva single-char items

Use character-width ratios instead of font_size for Canva-style PDFs:
- Single-char prev: gap/prev.width < 1.25
- Multi→single: gap/avg_prev_char_width < 1.25
- Multi→multi: page-level threshold (gap/font_size)

Also adds second Canva detection path for per-character rendering
without embedded spaces (>50% single-char items).

Improves ebgt7isj04ophcq word accuracy from ~3% to 64%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-12 23:23:02 -07:00
co-authored by Claude Opus 4.6
parent 152de8b56d
commit 04aa6d4ae5
+149 -36
View File
@@ -329,6 +329,19 @@ pub(crate) fn fix_letterspaced_items(items: &mut [TextItem]) -> f32 {
// Only fix if ≥50% of substantial items are letter-spaced // Only fix if ≥50% of substantial items are letter-spaced
if total_text_items < 4 || letterspaced_count * 2 < total_text_items { if total_text_items < 4 || letterspaced_count * 2 < total_text_items {
// Second detection path: per-character rendering without embedded spaces.
// Canva sometimes emits each character as a separate TextItem (no "a b c"
// pattern within items). Detect by checking if >50% of items are single chars.
let single_char_count = items
.iter()
.filter(|i| i.text.trim().chars().count() == 1)
.count();
if items.len() >= 10 && single_char_count * 2 >= items.len() {
let threshold = compute_canva_join_threshold(items);
if threshold > 0.40 {
return threshold;
}
}
return DEFAULT; return DEFAULT;
} }
// Compute threshold BEFORE removing spaces. Since we've confirmed this // Compute threshold BEFORE removing spaces. Since we've confirmed this
@@ -348,21 +361,39 @@ pub(crate) fn fix_letterspaced_items(items: &mut [TextItem]) -> f32 {
threshold threshold
} }
/// Compute Otsu join threshold for a confirmed Canva-style page. /// Compute join threshold for a confirmed Canva-style page.
/// ///
/// Like [`compute_single_char_join_threshold`] but without the per-pair /// Uses `median × 1.55` on the gap/font_size ratio distribution. The page-level
/// char-count guard, since we already know the page has Canva-style /// threshold is used for multi-char item pairs; single-char pairs use
/// letter-spacing. Uses all adjacent pairs for maximum sample size. /// character-widthbased joining in `should_join_items` instead.
fn compute_canva_join_threshold(items: &[TextItem]) -> f32 { fn compute_canva_join_threshold(items: &[TextItem]) -> f32 {
const DEFAULT: f32 = 0.10; const DEFAULT: f32 = 0.10;
const MIN_SAMPLES: usize = 8; const MIN_SAMPLES: usize = 8;
let ratios = collect_gap_ratios(items);
if ratios.len() < MIN_SAMPLES {
return DEFAULT;
}
let mut sorted: Vec<f32> = ratios;
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if sorted[sorted.len() - 1] < 0.40 || sorted[0] < 0.40 {
return DEFAULT;
}
let median = sorted[sorted.len() / 2];
(median * 1.55).clamp(0.50, 2.0)
}
/// Collect positive gap/font_size ratios from adjacent item pairs,
/// filtering out CJK, zero-width, and out-of-range values.
fn collect_gap_ratios(items: &[TextItem]) -> Vec<f32> {
let mut ratios: Vec<f32> = Vec::new(); let mut ratios: Vec<f32> = Vec::new();
for pair in items.windows(2) { for pair in items.windows(2) {
let prev = &pair[0]; let prev = &pair[0];
let curr = &pair[1]; let curr = &pair[1];
// Skip CJK pairs
let prev_c = prev.text.trim().chars().last(); let prev_c = prev.text.trim().chars().last();
let curr_c = curr.text.trim().chars().next(); let curr_c = curr.text.trim().chars().next();
if prev_c.is_some_and(is_cjk_char) || curr_c.is_some_and(is_cjk_char) { if prev_c.is_some_and(is_cjk_char) || curr_c.is_some_and(is_cjk_char) {
@@ -381,38 +412,11 @@ fn compute_canva_join_threshold(items: &[TextItem]) -> f32 {
let ratio = gap / prev.font_size; let ratio = gap / prev.font_size;
if !(0.0..=3.0).contains(&ratio) { if (0.0..=3.0).contains(&ratio) {
continue; ratios.push(ratio);
} }
ratios.push(ratio);
} }
ratios
if ratios.len() < MIN_SAMPLES {
return DEFAULT;
}
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// If all gaps are tight (max < 0.40), use default — normal PDF
let max_ratio = ratios[ratios.len() - 1];
if max_ratio < 0.40 {
return DEFAULT;
}
// If the minimum gap is below 0.40, there's a mix of tight and wide gaps,
// meaning this isn't a uniform letter-spacing PDF — use default.
if ratios[0] < 0.40 {
return DEFAULT;
}
// Median-based threshold: for Canva letter-spacing, the median gap ratio
// is dominated by the letter-spacing value (~0.55× font_size). Word gaps
// are consistently ~1.8× the letter-spacing. Using median × 1.55 places
// the threshold between the widest intra-word gaps and the narrowest
// inter-word gaps.
let median = ratios[ratios.len() / 2];
(median * 1.55).clamp(0.50, 2.0)
} }
/// Compute an adaptive join threshold for text items on a line. /// Compute an adaptive join threshold for text items on a line.
@@ -621,8 +625,25 @@ pub(crate) fn should_join_items(
} }
// When the adaptive threshold indicates Canva-style letter-spacing // When the adaptive threshold indicates Canva-style letter-spacing
// (all gaps wide), use it uniformly for all pair types. // (all gaps wide), use character-widthbased joining.
//
// Canva renders text character-by-character with CSS-style letter-spacing.
// For single-char prev items, gap/char_width gives a clean separation
// (~0.91.05 for letter gaps, ~1.5+ for word gaps).
// For multi-char prev, avg_char_width normalizes for character mix.
// Multi→multi pairs use the page-level threshold (gap/font_size).
if single_char_threshold > 0.20 { if single_char_threshold > 0.20 {
if prev_chars == 1 {
// Single-char prev: its rendered width is an accurate reference
return gap < prev_item.width * 1.25;
}
if curr_chars == 1 {
// Multi→single: avg char width of prev normalises for
// wide/narrow character mix (e.g. "ilw" includes i,l,w)
let avg_char_width = prev_item.width / prev_chars as f32;
return gap < avg_char_width * 1.25;
}
// Both multi-char: use page-level threshold
return gap < font_size * single_char_threshold; return gap < font_size * single_char_threshold;
} }
@@ -1036,4 +1057,96 @@ mod tests {
"o+W (word boundary) should NOT join with threshold {threshold}" "o+W (word boundary) should NOT join with threshold {threshold}"
); );
} }
/// Helper to create a multi-char TextItem at a given position.
fn make_text_item(text: &str, x: f32, width: f32, font_size: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y: 100.0,
width,
height: font_size,
font: "TestFont".to_string(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
}
}
#[test]
fn canva_width_based_single_char_prev_join() {
// Canva-style: single-char prev uses gap/prev.width < 1.25
let fs = 12.0;
let threshold = 0.90; // Canva page threshold
// "K" (w=7.9) → "a" (gap=8.12): letter gap, ratio=1.028 → JOIN
let k = make_text_item("K", 100.0, 7.9, fs);
let a = make_text_item("a", 115.9, 6.0, fs);
assert!(
should_join_items(&k, &a, threshold),
"K→a: gap/width={:.3}, should join",
(a.x - (k.x + k.width)) / k.width
);
// "f" (w=4.0) → "K" (gap=10.47): word boundary, ratio=2.618 → SPLIT
let f = make_text_item("f", 193.0, 4.0, fs);
let k2 = make_text_item("K", 207.47, 7.9, fs);
assert!(
!should_join_items(&f, &k2, threshold),
"f→K: gap/width={:.3}, should split",
(k2.x - (f.x + f.width)) / f.width
);
}
#[test]
fn canva_width_based_multi_to_single_join() {
// Multi→single: uses avg_char_width of prev
let fs = 12.0;
let threshold = 0.90;
// "ilw" (w=23.6, 3 chars) → "a" (gap=9.42): intra-word, avg=7.87, ratio=1.197 → JOIN
let ilw = make_text_item("ilw", 320.0, 23.6, fs);
let a = make_text_item("a", 353.0, 6.0, fs);
assert!(
should_join_items(&ilw, &a, threshold),
"ilw→a: avg_ratio={:.3}, should join (intra-word 'railway')",
(a.x - (ilw.x + ilw.width)) / (ilw.width / 3.0)
);
// "rich" (w=34.8, 4 chars) → "m" (gap=14.01): word boundary, avg=8.7, ratio=1.610 → SPLIT
let rich = make_text_item("rich", 229.0, 34.8, fs);
let m = make_text_item("m", 277.8, 10.7, fs);
assert!(
!should_join_items(&rich, &m, threshold),
"rich→m: avg_ratio={:.3}, should split (word boundary)",
(m.x - (rich.x + rich.width)) / (rich.width / 4.0)
);
}
#[test]
fn canva_width_based_multi_to_multi_page_threshold() {
// Multi→multi: uses page-level threshold (gap/font_size < threshold)
let fs = 12.0;
let threshold = 0.90;
// "rib" (w=25.0) → "ib" (gap=7.01): intra-word, r=0.584 → JOIN
let rib = make_text_item("rib", 236.0, 25.0, fs);
let ib = make_text_item("ib", 268.0, 14.0, fs);
assert!(
should_join_items(&rib, &ib, threshold),
"rib→ib: ratio={:.3}, should join (intra-word)",
(ib.x - (rib.x + rib.width)) / fs
);
// "ized" (w=35.9) → "fo" (gap=13.92): word boundary, r=1.160 → SPLIT
let ized = make_text_item("ized", 142.0, 35.9, fs);
let fo = make_text_item("fo", 191.8, 13.8, fs);
assert!(
!should_join_items(&ized, &fo, threshold),
"ized→fo: ratio={:.3}, should split (word boundary)",
(fo.x - (ized.x + ized.width)) / fs
);
}
} }