fix(text): Improve word spacing, paragraph joining, and page breaks

- Preserve leading whitespace in text_with_formatting() to fix ~80% of
  missing-space issues (e.g. "holdermeans" -> "holder means")
- Tighten word boundary gap threshold from 0.05 to 0.01 and add
  word-count heuristic to distinguish CID word-level operators from
  Type1 line-level operators, fixing spurious spaces (e.g. "t emporary")
- Replace fixed paragraph threshold (1.8x base_size) with dynamic
  median-based computation for double-spaced documents
- Remove --- page break markers between pages
- Fix page number y-threshold for US Letter (720pt vs 800pt)

Eval: +1.4% mean word_sim, zero char_sim regressions across 63 PDFs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-13 12:55:00 -08:00
co-authored by Claude Opus 4.6
parent b94df22d37
commit 8c4b9d2d07
3 changed files with 122 additions and 18 deletions
+61 -6
View File
@@ -271,6 +271,12 @@ fn to_markdown_from_lines_with_tables_and_images(
// Discover heading tiers for this document
let heading_tiers = compute_heading_tiers(&lines, base_size);
// Compute the typical line spacing for paragraph break detection.
// For double-spaced documents (like legal/government PDFs), the normal
// line spacing can be 2.3x base_size, which would exceed a fixed 1.8x
// threshold and cause every line to be treated as a paragraph break.
let para_threshold = compute_paragraph_threshold(&lines, base_size);
let mut output = String::new();
let mut current_page = 0u32;
let mut prev_y = f32::MAX;
@@ -317,7 +323,7 @@ fn to_markdown_from_lines_with_tables_and_images(
output.push_str("\n\n");
in_paragraph = false;
}
output.push_str("---\n\n");
output.push_str("\n\n");
}
current_page = line.page;
prev_y = f32::MAX;
@@ -357,9 +363,9 @@ fn to_markdown_from_lines_with_tables_and_images(
}
}
// Paragraph break (large Y gap)
// Paragraph break (large Y gap relative to document's typical line spacing)
let y_gap = prev_y - line.y;
let is_para_break = y_gap > base_size * 1.8; // Slightly lower threshold
let is_para_break = y_gap > para_threshold;
if is_para_break && in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
@@ -533,6 +539,9 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
// Discover heading tiers for this document
let heading_tiers = compute_heading_tiers(&lines, base_size);
// Compute the typical line spacing for paragraph break detection
let para_threshold = compute_paragraph_threshold(&lines, base_size);
let mut output = String::new();
let mut current_page = 0u32;
let mut prev_y = f32::MAX;
@@ -548,7 +557,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
output.push_str("\n\n");
in_paragraph = false;
}
output.push_str("---\n\n");
output.push_str("\n\n");
}
current_page = line.page;
prev_y = f32::MAX;
@@ -556,9 +565,9 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
last_list_x = None;
}
// Paragraph break (large Y gap)
// Paragraph break (large Y gap relative to document's typical line spacing)
let y_gap = prev_y - line.y;
let is_para_break = y_gap > base_size * 1.8; // Slightly lower threshold
let is_para_break = y_gap > para_threshold;
if is_para_break && in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
@@ -793,6 +802,52 @@ fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
FontStats { most_common_size }
}
/// Compute the Y-gap threshold for paragraph break detection.
///
/// Instead of using a fixed multiple of base_size (which fails for double-spaced
/// documents), we compute the document's typical (median) line spacing and use
/// a multiplier on that. A gap significantly larger than typical indicates a
/// paragraph break.
///
/// Fallback: if we can't compute typical spacing, use base_size * 1.8.
fn compute_paragraph_threshold(lines: &[TextLine], base_size: f32) -> f32 {
let fallback = base_size * 1.8;
// Collect Y gaps between consecutive lines on the same page
let mut gaps: Vec<f32> = Vec::new();
let mut prev_y: Option<(u32, f32)> = None;
for line in lines {
if let Some((prev_page, py)) = prev_y {
if line.page == prev_page {
let gap = py - line.y;
// Only consider positive gaps within a reasonable range
// (skip huge gaps from page headers/footers)
if gap > 0.0 && gap < base_size * 10.0 {
gaps.push(gap);
}
}
}
prev_y = Some((line.page, line.y));
}
if gaps.len() < 5 {
return fallback;
}
gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = gaps[gaps.len() / 2];
// The paragraph threshold should be larger than the typical line spacing.
// Use 1.3x the median gap. This means:
// - Single-spaced (median ~14pt for 12pt font): threshold = 18.2pt
// - Double-spaced (median ~28pt for 12pt font): threshold = 36.4pt
// Also ensure it's at least base_size * 1.5 to avoid false paragraph breaks
// in tightly-spaced documents.
(median * 1.3).max(base_size * 1.5)
}
/// Discover distinct heading font-size tiers in the document.
/// Returns tiers sorted largest-first (tier 0 = H1, tier 1 = H2, …).
/// Sizes within 0.5pt are clustered into the same tier. Capped at 4 tiers.