use super::types::PdfParagraph;
const MAX_CONTINUATION_LINE_GAP_MULTIPLE: f32 = 3.0;
pub(super) fn merge_continuation_paragraphs(paragraphs: &mut Vec<PdfParagraph>) {
if paragraphs.len() < 2 {
return;
}
let old = std::mem::take(paragraphs);
let mut iter = old.into_iter();
let mut current = iter.next().unwrap();
for next in iter {
let both_body = current.heading_level.is_none()
&& next.heading_level.is_none()
&& !current.is_list_item
&& !next.is_list_item
&& !current.is_code_block
&& !next.is_code_block
&& !current.is_formula
&& !next.is_formula;
let fonts_compatible = (current.dominant_font_size - next.dominant_font_size).abs() < 2.0;
let bold_compatible = current.is_bold == next.is_bold;
let continuation_signal = !ends_with_sentence_terminator(¤t) || starts_with_lowercase_continuation(&next);
let same_region = current.layout_region_path == next.layout_region_path;
let vertical_gap_compatible = baselines_within_continuation_gap(¤t, &next);
let should_merge = both_body
&& fonts_compatible
&& bold_compatible
&& continuation_signal
&& same_region
&& vertical_gap_compatible;
if should_merge {
current.text.clear();
current.block_bbox = union_block_bbox(current.block_bbox, next.block_bbox);
current.lines.extend(next.lines);
} else {
paragraphs.push(current);
current = next;
}
}
paragraphs.push(current);
}
fn baselines_within_continuation_gap(current: &PdfParagraph, next: &PdfParagraph) -> bool {
let (Some(current_last), Some(next_first)) = (current.lines.last(), next.lines.first()) else {
return true;
};
if current_last.baseline_y == 0.0 || next_first.baseline_y == 0.0 {
return true;
}
let gap = (current_last.baseline_y - next_first.baseline_y).abs();
let line_height = current.dominant_font_size.max(next.dominant_font_size).max(1.0);
gap <= line_height * MAX_CONTINUATION_LINE_GAP_MULTIPLE
}
fn union_block_bbox(
current: Option<(f32, f32, f32, f32)>,
next: Option<(f32, f32, f32, f32)>,
) -> Option<(f32, f32, f32, f32)> {
match (current, next) {
(Some((cl, cb, cr, ct)), Some((nl, nb, nr, nt))) => Some((cl.min(nl), cb.min(nb), cr.max(nr), ct.max(nt))),
(Some(bbox), None) | (None, Some(bbox)) => Some(bbox),
(None, None) => None,
}
}
fn starts_with_lowercase_continuation(para: &PdfParagraph) -> bool {
let first_text = para
.lines
.first()
.and_then(|l| l.segments.first())
.map(|s| s.text.trim_start())
.unwrap_or("");
first_text.chars().next().is_some_and(|c| c.is_lowercase())
}
fn ends_with_sentence_terminator(para: &PdfParagraph) -> bool {
let last_text = para
.lines
.last()
.and_then(|l| l.segments.last())
.map(|s| s.text.trim_end())
.unwrap_or("");
matches!(
last_text.chars().last(),
Some('.' | '?' | '!' | ':' | ';' | '\u{3002}' | '\u{FF1F}' | '\u{FF01}')
)
}
pub(super) fn split_embedded_list_items(paragraphs: &mut Vec<PdfParagraph>) {
let old = std::mem::take(paragraphs);
for para in old {
if para.heading_level.is_some() || para.is_list_item || para.is_code_block || para.is_formula {
paragraphs.push(para);
continue;
}
let full_text: String = para
.lines
.iter()
.flat_map(|l| l.segments.iter())
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ");
let bullet_count = full_text.matches(['\u{2022}', '\u{00B7}']).count();
if bullet_count < 2 {
paragraphs.push(para);
continue;
}
let font_size = para.dominant_font_size;
let is_bold = para.is_bold;
let parts: Vec<&str> = full_text.split(['\u{2022}', '\u{00B7}']).collect();
let before = parts[0].trim().trim_end_matches('\u{00C2}').trim();
if !before.is_empty() {
paragraphs.push(text_to_paragraph(before, font_size, is_bold, false));
}
for part in &parts[1..] {
let item_text = part
.trim()
.trim_start_matches('\u{00C2}')
.trim_end_matches('\u{00C2}')
.trim();
if !item_text.is_empty() {
paragraphs.push(text_to_paragraph(item_text, font_size, is_bold, true));
}
}
}
}
fn text_to_paragraph(text: &str, font_size: f32, is_bold: bool, is_list_item: bool) -> PdfParagraph {
use crate::pdf::hierarchy::SegmentData;
let segments: Vec<SegmentData> = text
.split_whitespace()
.map(|w| SegmentData {
text: w.to_string(),
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
font_size,
is_bold,
is_italic: false,
is_monospace: false,
baseline_y: 0.0,
assigned_role: None,
})
.collect();
let line = super::types::PdfLine {
segments,
baseline_y: 0.0,
dominant_font_size: font_size,
is_bold,
is_monospace: false,
};
let lines = vec![line];
let word_count = PdfParagraph::compute_word_count("", &lines);
PdfParagraph {
text: String::new(),
lines,
dominant_font_size: font_size,
heading_level: None,
is_bold,
is_list_item,
is_code_block: false,
is_formula: false,
is_page_furniture: false,
layout_class: None,
layout_region_path: None,
caption_for: None,
block_bbox: None,
word_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_body_paragraph(text: &str, font_size: f32) -> PdfParagraph {
use crate::pdf::hierarchy::SegmentData;
let segments = vec![SegmentData {
text: text.to_string(),
x: 0.0,
y: 700.0,
width: 200.0,
height: font_size,
font_size,
is_bold: false,
is_italic: false,
is_monospace: false,
baseline_y: 700.0,
assigned_role: None,
}];
let lines = vec![super::super::types::PdfLine {
segments,
baseline_y: 700.0,
dominant_font_size: font_size,
is_bold: false,
is_monospace: false,
}];
let word_count = PdfParagraph::compute_word_count("", &lines);
PdfParagraph {
text: String::new(),
lines,
dominant_font_size: font_size,
heading_level: None,
is_bold: false,
is_list_item: false,
is_code_block: false,
is_formula: false,
is_page_furniture: false,
layout_class: None,
layout_region_path: None,
caption_for: None,
block_bbox: None,
word_count,
}
}
fn make_body_paragraph_at(text: &str, font_size: f32, baseline_y: f32) -> PdfParagraph {
let mut para = make_body_paragraph(text, font_size);
para.lines[0].baseline_y = baseline_y;
if let Some(segment) = para.lines[0].segments.first_mut() {
segment.baseline_y = baseline_y;
segment.y = baseline_y;
}
para
}
#[test]
fn test_no_merge_across_distant_regions() {
let mut paragraphs = vec![
make_body_paragraph_at("Buyer tax ID SYNTH-BUYER-TAX-359370919", 8.0, 185.5),
make_body_paragraph_at("Seller tax ID SYNTH-SELLER-TAX-815876165", 8.0, 775.0),
];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(
paragraphs.len(),
2,
"paragraphs from spatially distant regions must not merge"
);
}
#[test]
fn test_merge_adjacent_lines_within_gap() {
let mut paragraphs = vec![
make_body_paragraph_at("The committee reviewed the annual", 11.0, 712.0),
make_body_paragraph_at("report and approved the budget", 11.0, 698.0),
];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 1, "adjacent continuation lines should merge");
}
#[test]
fn test_merge_unions_block_bbox() {
let mut upper = make_body_paragraph_at("first line without terminator", 11.0, 712.0);
upper.block_bbox = Some((60.0, 705.0, 260.0, 720.0));
let mut lower = make_body_paragraph_at("second line continues", 11.0, 698.0);
lower.block_bbox = Some((60.0, 691.0, 300.0, 706.0));
let mut paragraphs = vec![upper, lower];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 1, "adjacent lines should merge");
assert_eq!(
paragraphs[0].block_bbox,
Some((60.0, 691.0, 300.0, 720.0)),
"merged block bbox must span both source boxes"
);
}
#[test]
fn test_merge_lowercase_continuation() {
let mut paragraphs = vec![
make_body_paragraph("The regulation requires.", 12.0),
make_body_paragraph("and all operators must comply", 12.0),
];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 1, "lowercase continuation should be merged");
}
#[test]
fn test_no_merge_different_font_sizes() {
let mut paragraphs = vec![
make_body_paragraph("First paragraph", 12.0),
make_body_paragraph("second paragraph", 16.0),
];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 2, "different font sizes should prevent merge");
}
#[test]
fn test_merge_no_terminator() {
let mut paragraphs = vec![
make_body_paragraph("The regulation requires", 12.0),
make_body_paragraph("All operators must comply", 12.0),
];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 1, "unterminated paragraph should merge with next");
}
#[test]
fn test_no_merge_terminated_uppercase() {
let mut paragraphs = vec![
make_body_paragraph("The regulation requires compliance.", 12.0),
make_body_paragraph("All operators must comply", 12.0),
];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(
paragraphs.len(),
2,
"terminated paragraph + uppercase start should not merge"
);
}
#[test]
fn test_no_merge_across_bold_boundary() {
let body = make_body_paragraph(
"here is also available other sources of this Manual MetcalUser Guide",
12.0,
);
let mut header = make_body_paragraph("Impaired Glucose Tolerance And Impaired Fasting Glucose ...", 12.0);
header.is_bold = true;
let mut paragraphs = vec![body, header];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 2, "bold header must not merge into non-bold prose");
assert!(paragraphs[1].is_bold, "the bold header paragraph must be preserved");
}
#[test]
fn test_starts_with_lowercase_continuation_fn() {
let para_lower = make_body_paragraph("and furthermore", 12.0);
assert!(starts_with_lowercase_continuation(¶_lower));
let para_upper = make_body_paragraph("Furthermore", 12.0);
assert!(!starts_with_lowercase_continuation(¶_upper));
}
#[test]
fn test_merge_clears_precomputed_text_on_heuristic_path() {
let mut p1 = make_body_paragraph("een indicative", 12.0);
p1.text = "een indicative".to_string();
let mut p2 = make_body_paragraph("van toenemende merkbekendheid", 12.0);
p2.text = "van toenemende merkbekendheid".to_string();
let mut paragraphs = vec![p1, p2];
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 1, "lowercase continuation should merge");
assert!(
paragraphs[0].text.is_empty(),
"merged paragraph must clear pre-computed text so assembly joins from segments"
);
assert_eq!(paragraphs[0].lines.len(), 2, "both lines must be present after merge");
}
#[test]
fn test_merge_struct_tree_path_text_stays_empty() {
let mut paragraphs = vec![
make_body_paragraph("first sentence without terminator", 12.0),
make_body_paragraph("second continues here", 12.0),
];
assert!(paragraphs[0].text.is_empty());
merge_continuation_paragraphs(&mut paragraphs);
assert_eq!(paragraphs.len(), 1);
assert!(paragraphs[0].text.is_empty());
}
}