diff --git a/src/markdown.rs b/src/markdown.rs index 85cd12b..02b1b44 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -271,6 +271,9 @@ fn to_markdown_from_lines_with_tables_and_images( // Discover heading tiers for this document let heading_tiers = compute_heading_tiers(&lines, base_size); + // Merge consecutive heading lines at the same level (e.g., wrapped titles) + let lines = merge_heading_lines(lines, base_size, &heading_tiers); + // 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 @@ -400,7 +403,11 @@ fn to_markdown_from_lines_with_tables_and_images( // Detect headers by font size // Note: Headers typically shouldn't have bold markers since they're already emphasized - if options.detect_headers && plain_trimmed.len() > 3 { + // Skip very short text (drop caps/labels) and very long text (body paragraphs) + if options.detect_headers + && plain_trimmed.len() > 3 + && plain_trimmed.split_whitespace().count() <= 15 + { let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size); if let Some(header_level) = detect_header_level(line_font_size, base_size, &heading_tiers) @@ -539,6 +546,9 @@ pub fn to_markdown_from_lines(lines: Vec, options: MarkdownOptions) -> // Discover heading tiers for this document let heading_tiers = compute_heading_tiers(&lines, base_size); + // Merge consecutive heading lines at the same level (e.g., wrapped titles) + let lines = merge_heading_lines(lines, base_size, &heading_tiers); + // Compute the typical line spacing for paragraph break detection let para_threshold = compute_paragraph_threshold(&lines, base_size); @@ -601,8 +611,11 @@ pub fn to_markdown_from_lines(lines: Vec, options: MarkdownOptions) -> } // Detect headers by font size - // Skip very short text (likely drop caps or labels) - if options.detect_headers && plain_trimmed.len() > 3 { + // Skip very short text (drop caps/labels) and very long text (body paragraphs) + if options.detect_headers + && plain_trimmed.len() > 3 + && plain_trimmed.split_whitespace().count() <= 15 + { let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size); if let Some(header_level) = detect_header_level(line_font_size, base_size, &heading_tiers) @@ -695,6 +708,61 @@ pub fn to_markdown_from_lines(lines: Vec, options: MarkdownOptions) -> /// Merge drop caps with the appropriate line /// A drop cap is a single large letter at the start of a paragraph /// Due to PDF coordinate sorting, the drop cap may appear AFTER the line it belongs to +/// Merge consecutive heading lines at the same level into a single line. +/// +/// When a heading wraps across multiple text lines (e.g., "About Glenair, the Mission-Critical" +/// and "Interconnect Company"), each fragment becomes a separate `# Header` in the output. +/// This function detects consecutive lines at the same heading tier on the same page +/// with a small Y gap and merges them into one line. +fn merge_heading_lines( + lines: Vec, + base_size: f32, + heading_tiers: &[f32], +) -> Vec { + if lines.is_empty() { + return lines; + } + + let mut result: Vec = Vec::with_capacity(lines.len()); + + for line in lines { + let line_font = line.items.first().map(|i| i.font_size).unwrap_or(base_size); + let line_level = detect_header_level(line_font, base_size, heading_tiers); + + // Check if the previous line is a heading at the same level on the same page + let should_merge = if let (Some(prev), Some(curr_level)) = (result.last(), line_level) { + let prev_font = prev.items.first().map(|i| i.font_size).unwrap_or(base_size); + let prev_level = detect_header_level(prev_font, base_size, heading_tiers); + let same_page = prev.page == line.page; + let same_level = prev_level == Some(curr_level); + let y_gap = prev.y - line.y; + // Merge if gap is within ~2x the font size (normal line wrap spacing) + let close_enough = y_gap > 0.0 && y_gap < line_font * 2.0; + same_page && same_level && close_enough + } else { + false + }; + + if should_merge { + // Append this line's items to the previous line + let prev = result.last_mut().unwrap(); + // Add a space-bearing TextItem to separate the merged text + if let Some(first_item) = line.items.first() { + let mut space_item = first_item.clone(); + space_item.text = format!(" {}", space_item.text.trim_start()); + prev.items.push(space_item); + } + for item in line.items.into_iter().skip(1) { + prev.items.push(item); + } + } else { + result.push(line); + } + } + + result +} + fn merge_drop_caps(lines: Vec, base_size: f32) -> Vec { let mut result: Vec = Vec::with_capacity(lines.len()); @@ -856,7 +924,7 @@ fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec { for line in lines { if let Some(first) = line.items.first() { - if first.font_size / base_size >= 1.1 { + if first.font_size / base_size >= 1.2 { heading_sizes.push(first.font_size); } } @@ -885,7 +953,7 @@ fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec { fn detect_header_level(font_size: f32, base_size: f32, heading_tiers: &[f32]) -> Option { let ratio = font_size / base_size; - if ratio < 1.1 { + if ratio < 1.2 { return None; // Regular text } @@ -1394,9 +1462,10 @@ mod tests { assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(3)); assert_eq!(detect_header_level(12.0, 12.0, &tiers), None); - // Single tier: 14→H1, 12→None - let tiers = vec![14.0]; - assert_eq!(detect_header_level(14.0, 12.0, &tiers), Some(1)); + // Single tier: 15→H1 (ratio 1.25 ≥ 1.2), 14→None (ratio 1.17 < 1.2) + let tiers = vec![15.0]; + assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(1)); + assert_eq!(detect_header_level(14.0, 12.0, &tiers), None); assert_eq!(detect_header_level(12.0, 12.0, &tiers), None); // No tiers (empty): falls back to ratio thresholds @@ -1404,7 +1473,8 @@ mod tests { assert_eq!(detect_header_level(24.0, 12.0, &tiers), Some(1)); assert_eq!(detect_header_level(18.0, 12.0, &tiers), Some(2)); assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(3)); - assert_eq!(detect_header_level(13.5, 12.0, &tiers), Some(4)); + assert_eq!(detect_header_level(14.5, 12.0, &tiers), Some(4)); + assert_eq!(detect_header_level(14.0, 12.0, &tiers), None); assert_eq!(detect_header_level(12.0, 12.0, &tiers), None); // Body text excluded when tiers exist: 13pt (ratio 1.08) → None diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1cf89e1..4f7f537 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -695,12 +695,12 @@ fn test_header_level_h3() { #[test] fn test_header_level_h4() { use pdf_inspector::markdown::to_markdown_from_items; - // Four heading tiers: 24.0 → H1, 18.0 → H2, 15.0 → H3, 13.5 → H4 + // Four heading tiers: 24.0 → H1, 18.0 → H2, 15.0 → H3, 14.5 → H4 let items = vec![ make_text_item("H1 Title", 100.0, 850.0, 24.0, 1), make_text_item("H2 Title", 100.0, 800.0, 18.0, 1), make_text_item("H3 Title", 100.0, 750.0, 15.0, 1), - make_text_item("H4 Title", 100.0, 700.0, 13.5, 1), + make_text_item("H4 Title", 100.0, 700.0, 14.5, 1), make_text_item("body text one", 100.0, 650.0, 12.0, 1), make_text_item("body text two", 100.0, 630.0, 12.0, 1), make_text_item("body text three", 100.0, 610.0, 12.0, 1),