use std::borrow::Cow;
use crate::pdf::bookmarks::PdfOutlineEntry;
use crate::pdf::error::Result;
use crate::pdf::hierarchy::{BoundingBox, SegmentData, TextBlock, assign_heading_levels_smart, cluster_font_sizes};
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
use super::assembly::assemble_internal_document;
use super::classify::{
classify_paragraphs, demote_heading_runs, demote_structure_annotation_headings, demote_unnumbered_subsections,
is_body_size_bold_heading_candidate, is_body_size_bold_signal, is_numbered_section_heading, mark_arxiv_noise,
mark_cross_page_repeating_short_text, mark_cross_page_repeating_text, refine_heading_hierarchy,
};
use super::constants::{FULL_LINE_FRACTION, MIN_BLOCKS_FOR_FONT_HEADING, MIN_HEADING_FONT_GAP, MIN_HEADING_FONT_RATIO};
use super::lines::{is_cjk_char, segments_need_space};
use super::paragraphs::{merge_continuation_paragraphs, split_embedded_list_items};
use super::text_repair::{
MIN_LIGATURE_WITNESS_WORD_LEN, WordWitnesses, apply_to_all_segments, clean_duplicate_punctuation,
collapse_spaced_hyphens, expand_ligatures_with_space_absorption, normalize_text_encoding, normalize_unicode_text,
repair_contextual_ligatures, repair_ligature_spaces,
};
use super::types::{LayoutHint, PdfParagraph};
const SPARSE_REPEATED_TIER_MIN_PAGES: usize = 2;
const SPARSE_FONT_TIER_CLUSTER_COUNT: usize = 2;
const MIN_BODY_SIZE_BOLD_SIGNALS: usize = 3;
const MAX_OTHER_HEADING_RATIO: usize = 2;
const BODY_SIZE_BOLD_ALIGNMENT_TOLERANCE_EM: f32 = 1.5;
const MIN_OPENED_BODY_WORDS: usize = 4;
const SAME_ROW_MIN_VERTICAL_OVERLAP_RATIO: f32 = 0.5;
const SPARSE_FONT_TIER_TOLERANCE: f32 = 0.5;
const SPARSE_REPEATED_TIER_HEADING_LEVEL: u8 = 2;
type HeadingMap = Vec<(f32, Option<u8>)>;
pub(super) type HyphenWitnesses = ahash::AHashSet<(String, String)>;
#[derive(Default)]
struct TextRepairWitnesses {
hyphens: HyphenWitnesses,
words: WordWitnesses,
}
fn sparse_multi_page_heading_map(
all_page_segments: &[Vec<SegmentData>],
heuristic_pages: &[usize],
all_blocks: &[TextBlock],
has_struct_tree_blocks: bool,
) -> Result<Option<HeadingMap>> {
if has_struct_tree_blocks || heuristic_pages.len() < SPARSE_REPEATED_TIER_MIN_PAGES {
return Ok(None);
}
let clusters = cluster_font_sizes(all_blocks, SPARSE_FONT_TIER_CLUSTER_COUNT)?;
if clusters.len() != SPARSE_FONT_TIER_CLUSTER_COUNT {
return Ok(None);
}
let has_only_two_narrow_font_tiers = all_blocks.iter().all(|block| {
block.font_size.is_finite()
&& clusters
.iter()
.any(|cluster| (block.font_size - cluster.centroid).abs() <= SPARSE_FONT_TIER_TOLERANCE)
});
if !has_only_two_narrow_font_tiers {
return Ok(None);
}
let heading_font_size = clusters[0].centroid;
let body_font_size = clusters[1].centroid;
let has_distinct_body_tier = heading_font_size - body_font_size > SPARSE_FONT_TIER_TOLERANCE;
let clears_font_gate = heading_font_size >= body_font_size * MIN_HEADING_FONT_RATIO
&& heading_font_size >= body_font_size + MIN_HEADING_FONT_GAP;
if !has_distinct_body_tier || !clears_font_gate {
return Ok(None);
}
let repeated_pages: ahash::AHashSet<usize> = heuristic_pages
.iter()
.copied()
.filter(|&page_index| {
all_page_segments[page_index]
.iter()
.find(|segment| !segment.text.trim().is_empty())
.is_some_and(|segment| {
segment.font_size.is_finite()
&& (segment.font_size - heading_font_size).abs() <= SPARSE_FONT_TIER_TOLERANCE
})
})
.collect();
if repeated_pages.len() < SPARSE_REPEATED_TIER_MIN_PAGES {
return Ok(None);
}
Ok(Some(
clusters
.iter()
.map(|cluster| {
let level = ((cluster.centroid - heading_font_size).abs() <= SPARSE_FONT_TIER_TOLERANCE)
.then_some(SPARSE_REPEATED_TIER_HEADING_LEVEL);
(cluster.centroid, level)
})
.collect(),
))
}
#[allow(clippy::type_complexity)]
fn build_heading_map(
all_page_segments: &[Vec<SegmentData>],
struct_tree_results: &[Option<Vec<PdfParagraph>>],
heuristic_pages: &[usize],
k_clusters: usize,
) -> Result<(Vec<(f32, Option<u8>)>, ahash::AHashSet<usize>)> {
let struct_tree_needs_classify: ahash::AHashSet<usize> = struct_tree_results
.iter()
.enumerate()
.filter_map(|(i, result)| {
result.as_ref().and_then(|paragraphs| {
let has_headings = paragraphs.iter().any(|p| p.heading_level.is_some());
let has_untagged_bold = paragraphs
.iter()
.any(|p| p.heading_level.is_none() && p.is_bold && !p.is_list_item);
if (!has_headings && has_font_size_variation(paragraphs)) || has_untagged_bold {
Some(i)
} else {
None
}
})
})
.collect();
let mut all_blocks: Vec<TextBlock> = Vec::new();
let empty_bbox = BoundingBox {
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 0.0,
};
for &i in heuristic_pages {
for seg in &all_page_segments[i] {
if seg.text.trim().is_empty() {
continue;
}
all_blocks.push(TextBlock {
text: seg.text.clone(),
bbox: empty_bbox,
font_size: seg.font_size,
});
}
}
for &i in &struct_tree_needs_classify {
if let Some(paragraphs) = &struct_tree_results[i] {
for para in paragraphs {
let text = if !para.text.is_empty() {
para.text.clone()
} else {
para.lines
.iter()
.flat_map(|l| l.segments.iter())
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
};
all_blocks.push(TextBlock {
text,
bbox: empty_bbox,
font_size: para.dominant_font_size,
});
}
}
}
let paragraph_count = all_blocks.len();
let heading_map = if all_blocks.is_empty() {
Vec::new()
} else if paragraph_count < MIN_BLOCKS_FOR_FONT_HEADING {
if let Some(map) = sparse_multi_page_heading_map(
all_page_segments,
heuristic_pages,
&all_blocks,
!struct_tree_needs_classify.is_empty(),
)? {
tracing::debug!(
paragraph_count,
"heading map: promoting a repeated sparse font tier across pages"
);
map
} else {
tracing::debug!(
paragraph_count,
min_blocks = MIN_BLOCKS_FOR_FONT_HEADING,
"heading map: document too sparse for font-size heading inference; suppressing promotion"
);
let clusters = cluster_font_sizes(&all_blocks, 1)?;
clusters.iter().map(|c| (c.centroid, None)).collect()
}
} else {
let effective_k = if paragraph_count < 20 {
k_clusters.min(2usize.max(paragraph_count / 4))
} else {
k_clusters
};
let clusters = cluster_font_sizes(&all_blocks, effective_k)?;
let mut map = assign_heading_levels_smart(&clusters, MIN_HEADING_FONT_RATIO);
let has_any_heading = map.iter().any(|(_, level)| level.is_some());
if !has_any_heading && !heuristic_pages.is_empty() {
let first_page = heuristic_pages[0];
let first_seg_font = all_page_segments[first_page]
.iter()
.find(|s| !s.text.trim().is_empty())
.map(|s| s.font_size);
if let Some(first_font) = first_seg_font {
let mut sizes: Vec<f32> = all_blocks.iter().map(|b| b.font_size).collect();
sizes.sort_by(|a, b| a.total_cmp(b));
let median = if sizes.is_empty() { 0.0 } else { sizes[sizes.len() / 2] };
if median > 0.0
&& first_font >= median * 1.2
&& let Some(entry) = map.iter_mut().find(|(fs, _)| (*fs - first_font).abs() < 0.5)
{
entry.1 = Some(1);
}
}
}
map
};
Ok((heading_map, struct_tree_needs_classify))
}
fn bbox_coordinates_are_finite(bbox: (f32, f32, f32, f32)) -> bool {
let (left, bottom, right, top) = bbox;
left.is_finite() && bottom.is_finite() && right.is_finite() && top.is_finite()
}
fn shares_same_text_row(candidate: &PdfParagraph, following: &PdfParagraph, is_same_page: bool) -> bool {
if !is_same_page {
return false;
}
let (Some(candidate_bbox), Some(following_bbox)) = (candidate.block_bbox, following.block_bbox) else {
return false;
};
if !bbox_coordinates_are_finite(candidate_bbox) || !bbox_coordinates_are_finite(following_bbox) {
return false;
}
let candidate_bottom = candidate_bbox.1.min(candidate_bbox.3);
let candidate_top = candidate_bbox.1.max(candidate_bbox.3);
let following_bottom = following_bbox.1.min(following_bbox.3);
let following_top = following_bbox.1.max(following_bbox.3);
let minimum_height = (candidate_top - candidate_bottom).min(following_top - following_bottom);
let overlap = candidate_top.min(following_top) - candidate_bottom.max(following_bottom);
minimum_height > 0.0 && overlap.max(0.0) / minimum_height >= SAME_ROW_MIN_VERTICAL_OVERLAP_RATIO
}
fn opens_aligned_body_block(
candidate: &PdfParagraph,
following: &PdfParagraph,
body_font_size: f32,
is_same_page: bool,
) -> bool {
if following.word_count < MIN_OPENED_BODY_WORDS
|| following.heading_level.is_some()
|| is_body_size_bold_signal(following, body_font_size)
{
return false;
}
if candidate
.block_bbox
.is_some_and(|bbox| !bbox_coordinates_are_finite(bbox))
|| following
.block_bbox
.is_some_and(|bbox| !bbox_coordinates_are_finite(bbox))
{
return false;
}
let (Some(candidate_bbox), Some(following_bbox)) = (candidate.block_bbox, following.block_bbox) else {
return true;
};
if shares_same_text_row(candidate, following, is_same_page) {
return false;
}
let candidate_left = candidate_bbox.0.min(candidate_bbox.2);
let following_left = following_bbox.0.min(following_bbox.2);
candidate_left <= following_left + body_font_size * BODY_SIZE_BOLD_ALIGNMENT_TOLERANCE_EM
}
fn is_explicit_numbered_body_size_section(text: &str) -> bool {
let trimmed = text.trim();
if !is_numbered_section_heading(trimmed) {
return false;
}
let bytes = trimmed.as_bytes();
let roman_prefix_length = bytes
.iter()
.position(|byte| !b"IVXLCDM".contains(byte))
.unwrap_or(bytes.len());
if roman_prefix_length == 0 || bytes.get(roman_prefix_length) != Some(&b' ') {
return true;
}
let remainder = trimmed[roman_prefix_length..].trim_start();
remainder.chars().any(char::is_alphabetic)
&& remainder
.chars()
.filter(|character| character.is_alphabetic())
.all(char::is_uppercase)
}
fn body_size_heading_eligibility(all_pages: &[Vec<PdfParagraph>], body_font_size: f32) -> Vec<Vec<bool>> {
let mut next_content: Option<(usize, &PdfParagraph)> = None;
let mut eligibility = Vec::with_capacity(all_pages.len());
for (page_index, page) in all_pages.iter().enumerate().rev() {
let mut page_eligibility = Vec::with_capacity(page.len());
for paragraph in page.iter().rev() {
let is_candidate = is_body_size_bold_heading_candidate(paragraph, body_font_size);
let is_explicit_section = is_explicit_numbered_body_size_section(paragraph_text_raw(paragraph).trim());
let has_non_finite_geometry = paragraph
.block_bbox
.is_some_and(|bbox| !bbox_coordinates_are_finite(bbox))
|| next_content.is_some_and(|(_, following)| {
following
.block_bbox
.is_some_and(|bbox| !bbox_coordinates_are_finite(bbox))
});
page_eligibility.push(
is_candidate
&& !has_non_finite_geometry
&& if is_explicit_section {
!next_content.is_some_and(|(following_page_index, following)| {
shares_same_text_row(paragraph, following, page_index == following_page_index)
})
} else {
next_content.is_some_and(|(following_page_index, following)| {
opens_aligned_body_block(
paragraph,
following,
body_font_size,
page_index == following_page_index,
)
})
},
);
if paragraph.word_count > 0 && !paragraph.is_page_furniture {
next_content = Some((page_index, paragraph));
}
}
page_eligibility.reverse();
eligibility.push(page_eligibility);
}
eligibility.reverse();
eligibility
}
fn promote_repeated_body_size_bold_headings(all_pages: &mut [Vec<PdfParagraph>], body_font_size: Option<f32>) {
let Some(body_font_size) = body_font_size else {
return;
};
let eligible = body_size_heading_eligibility(all_pages, body_font_size);
let signal_count = eligible.iter().flatten().filter(|&&is_eligible| is_eligible).count();
let other_heading_count = all_pages
.iter()
.flatten()
.filter(|paragraph| paragraph.heading_level.is_some())
.count();
if signal_count < MIN_BODY_SIZE_BOLD_SIGNALS
|| signal_count <= other_heading_count.saturating_mul(MAX_OTHER_HEADING_RATIO)
{
return;
}
for (page, page_eligibility) in all_pages.iter_mut().zip(eligible) {
for (paragraph, is_eligible) in page.iter_mut().zip(page_eligibility) {
if is_eligible {
paragraph.heading_level = Some(3);
}
}
}
}
fn build_heading_map_from_assigned_roles(all_page_segments: &[Vec<SegmentData>]) -> Vec<(f32, Option<u8>)> {
use std::collections::HashMap;
let mut size_roles: HashMap<u32, Vec<Option<u8>>> = HashMap::new();
for page_segs in all_page_segments {
for seg in page_segs {
if seg.text.trim().is_empty() {
continue;
}
let key = (seg.font_size * 10.0).round() as u32;
size_roles.entry(key).or_default().push(seg.assigned_role);
}
}
let mut heading_map: Vec<(f32, Option<u8>)> = size_roles
.into_iter()
.map(|(quantized_size, roles)| {
let font_size = quantized_size as f32 / 10.0;
let total = roles.len();
let mut level_counts: HashMap<u8, usize> = HashMap::new();
let mut none_count = 0usize;
for role in &roles {
match role {
Some(level) => *level_counts.entry(*level).or_default() += 1,
None => none_count += 1,
}
}
let dominant_level = level_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.and_then(|(level, count)| if count * 2 >= total { Some(level) } else { None });
if none_count > total / 2 && dominant_level.is_none() {
(font_size, None)
} else {
(font_size, dominant_level)
}
})
.collect();
heading_map.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
heading_map
}
const LOGICAL_BLOCK_FONT_TOLERANCE: f32 = 1.5;
fn count_logical_blocks(all_page_segments: &[Vec<SegmentData>]) -> usize {
let mut total = 0usize;
for page_segs in all_page_segments {
let mut prev: Option<&SegmentData> = None;
for seg in page_segs {
if seg.text.trim().is_empty() {
continue;
}
let continues_prev = prev.is_some_and(|p| {
p.assigned_role == seg.assigned_role
&& (p.font_size - seg.font_size).abs() <= LOGICAL_BLOCK_FONT_TOLERANCE
});
if !continues_prev {
total += 1;
}
prev = Some(seg);
}
}
total
}
fn suppress_all_heading_roles_when_sparse_and_untrusted(
heading_map: &mut Vec<(f32, Option<u8>)>,
all_page_segments: &mut [Vec<SegmentData>],
) -> bool {
let total_blocks = count_logical_blocks(all_page_segments);
let has_any_heading = heading_map.iter().any(|(_, level)| level.is_some());
if total_blocks == 0 || total_blocks >= MIN_BLOCKS_FOR_FONT_HEADING || !has_any_heading {
return false;
}
tracing::debug!(
total_blocks,
min_blocks = MIN_BLOCKS_FOR_FONT_HEADING,
"structure tree: document too sparse to trust tagged heading roles; suppressing all heading roles"
);
for page_segs in all_page_segments.iter_mut() {
for seg in page_segs.iter_mut() {
seg.assigned_role = None;
}
}
heading_map.clear();
true
}
fn promote_untagged_document_title(
heading_map: &mut [(f32, Option<u8>)],
all_page_segments: &[Vec<SegmentData>],
) -> bool {
const MAX_TITLE_SEGMENTS: usize = 3;
let Some(max_heading_font) = heading_map
.iter()
.filter(|(_, level)| level.is_some())
.map(|(font, _)| *font)
.fold(None, |acc: Option<f32>, f| Some(acc.map_or(f, |a| a.max(f))))
else {
return false;
};
let candidate = heading_map
.iter()
.position(|(font, level)| level.is_none() && *font > max_heading_font);
let Some(candidate_idx) = candidate else {
return false;
};
let candidate_font = heading_map[candidate_idx].0;
let mut tier_segments = 0usize;
let mut all_bold = true;
let mut on_first_page = false;
for (page_idx, page_segs) in all_page_segments.iter().enumerate() {
for seg in page_segs {
if seg.text.trim().is_empty() || (seg.font_size - candidate_font).abs() >= 0.05 {
continue;
}
tier_segments += 1;
all_bold &= seg.is_bold;
on_first_page |= page_idx == 0;
}
}
if tier_segments == 0 || tier_segments > MAX_TITLE_SEGMENTS || !all_bold || !on_first_page {
return false;
}
tracing::debug!(
title_font = candidate_font,
max_heading_font,
tier_segments,
"structure tree: promoting untagged document-title tier to h1, demoting tagged levels"
);
for (font, level) in heading_map.iter_mut() {
if let Some(l) = level {
*level = Some((*l + 1).min(6));
} else if (*font - candidate_font).abs() < 0.05 {
*level = Some(1);
}
}
true
}
fn demote_assigned_roles(all_page_segments: &mut [Vec<SegmentData>]) {
for page_segs in all_page_segments.iter_mut() {
for seg in page_segs.iter_mut() {
if let Some(role) = seg.assigned_role {
seg.assigned_role = Some((role + 1).min(6));
}
}
}
}
struct PageInput {
page_index: usize,
struct_paragraphs: Option<Vec<PdfParagraph>>,
heuristic_segments: Vec<SegmentData>,
page_hints: Option<Vec<LayoutHint>>,
table_bboxes: Vec<TableCoverage>,
preserve_native_semantics: bool,
use_layout_reading_order: bool,
#[cfg(feature = "layout-detection")]
hint_validations: Vec<super::regions::layout_validation::RegionValidation>,
#[cfg(feature = "layout-detection")]
page_width_pts: Option<f32>,
needs_classify: bool,
paragraph_gap_ys: Vec<f32>,
include_headers: bool,
include_footers: bool,
include_footnotes: bool,
}
fn process_single_page(
input: PageInput,
heading_map: &[(f32, Option<u8>)],
doc_body_font_size: Option<f32>,
witnesses: &TextRepairWitnesses,
) -> Vec<PdfParagraph> {
let PageInput {
page_index: i,
struct_paragraphs,
heuristic_segments,
page_hints,
table_bboxes,
preserve_native_semantics,
use_layout_reading_order,
#[cfg(feature = "layout-detection")]
hint_validations,
#[cfg(feature = "layout-detection")]
page_width_pts,
needs_classify,
paragraph_gap_ys,
include_headers,
include_footers,
include_footnotes,
} = input;
#[cfg(not(feature = "layout-detection"))]
let _ = preserve_native_semantics;
#[cfg(not(feature = "layout-detection"))]
let _ = use_layout_reading_order;
if let Some(mut paragraphs) = struct_paragraphs {
apply_text_repair_to_structure_tree_paragraphs(&mut paragraphs, true, witnesses);
if needs_classify {
tracing::debug!(
page = i,
"PDF structure pipeline: classifying struct tree page via font-size clustering"
);
classify_paragraphs(&mut paragraphs, heading_map);
}
merge_continuation_paragraphs(&mut paragraphs);
synchronize_paragraph_text_metadata(&mut paragraphs);
merge_spatial_footnote_markers(&mut paragraphs);
if let Some(ref hints) = page_hints {
let classification_hints = regular_layout_hints(hints);
super::layout_classify::apply_layout_overrides(
&mut paragraphs,
&classification_hints,
0.5,
0.2,
doc_body_font_size,
);
un_mark_layout_furniture_per_config(&mut paragraphs, include_headers, include_footers, include_footnotes);
tracing::debug!(
page = i,
headings = paragraphs.iter().filter(|p| p.heading_level.is_some()).count(),
lists = paragraphs.iter().filter(|p| p.is_list_item).count(),
furniture = paragraphs.iter().filter(|p| p.is_page_furniture).count(),
"layout overrides applied"
);
retain_page_furniture_safely(&mut paragraphs);
}
demote_structure_annotation_headings(&mut paragraphs);
paragraphs
} else {
let page_segments = heuristic_segments;
tracing::debug!(
page = i,
segments = page_segments.len(),
has_layout_hints = page_hints.is_some(),
"process_single_page: heuristic path"
);
let page_segments = filter_segments_by_table_bboxes(page_segments, &table_bboxes);
#[cfg(feature = "layout-detection")]
let mut paragraphs = if let Some(ref hints) = page_hints {
let wrapper_ownership = wrapper_ownership_by_hint(hints, &hint_validations);
if use_layout_reading_order
&& crate::extractors::pdf::reading_order::has_eligible_layout_hints(hints, &wrapper_ownership)
{
process_layout_segment_groups(
page_segments,
hints,
&wrapper_ownership,
LayoutParagraphContext {
heading_map,
paragraph_gap_ys: ¶graph_gap_ys,
doc_body_font_size,
include_headers,
include_footers,
include_footnotes,
page_width_pts,
apply_layout_overrides: !preserve_native_semantics,
witnesses,
},
)
} else {
let mut paragraphs = segments_to_paragraphs(page_segments, heading_map, ¶graph_gap_ys, witnesses);
let classification_hints = regular_layout_hints(hints);
super::layout_classify::annotate_layout_classes(&mut paragraphs, &classification_hints, 0.5, 0.2);
paragraphs
}
} else {
segments_to_paragraphs(page_segments, heading_map, ¶graph_gap_ys, witnesses)
};
#[cfg(not(feature = "layout-detection"))]
let mut paragraphs = segments_to_paragraphs(page_segments, heading_map, ¶graph_gap_ys, witnesses);
tracing::debug!(
page = i,
paragraphs = paragraphs.len(),
"heuristic paragraphs classified"
);
#[cfg(not(feature = "layout-detection"))]
if let Some(ref hints) = page_hints {
let classification_hints = regular_layout_hints(hints);
super::layout_classify::apply_layout_overrides(
&mut paragraphs,
&classification_hints,
0.5,
0.2,
doc_body_font_size,
);
un_mark_layout_furniture_per_config(&mut paragraphs, include_headers, include_footers, include_footnotes);
}
if page_hints.is_some() {
tracing::debug!(
page = i,
headings = paragraphs.iter().filter(|p| p.heading_level.is_some()).count(),
lists = paragraphs.iter().filter(|p| p.is_list_item).count(),
furniture = paragraphs.iter().filter(|p| p.is_page_furniture).count(),
"layout overrides applied"
);
}
demote_structure_annotation_headings(&mut paragraphs);
merge_spatial_footnote_markers(&mut paragraphs);
retain_page_furniture_safely(&mut paragraphs);
paragraphs
}
}
const TABLE_DOMINANT_MIN_BODY_ROWS: usize = 40;
const TABLE_DOMINANT_MIN_VISIBLE_CHAR_SHARE: f64 = 0.85;
const TABLE_SPILL_MIN_PARAGRAPH_OVERLAP: f64 = 0.2;
fn suppress_table_dominant_paragraph_spill(pages: &mut [Vec<PdfParagraph>], emitted_tables: &[crate::types::Table]) {
for (page_index, paragraphs) in pages.iter_mut().enumerate() {
let page_number = page_index.saturating_add(1) as u32;
let page_tables = emitted_tables
.iter()
.filter(|table| table.page_number == page_number)
.collect::<Vec<_>>();
let body_rows = page_tables
.iter()
.map(|table| table.cells.len().saturating_sub(1))
.sum::<usize>();
if body_rows < TABLE_DOMINANT_MIN_BODY_ROWS {
continue;
}
let table_chars = page_tables
.iter()
.flat_map(|table| table.cells.iter().flatten())
.map(|cell| visible_char_count(cell))
.sum::<usize>();
let paragraph_chars = paragraphs
.iter()
.map(paragraph_text_raw)
.map(|text| visible_char_count(&text))
.sum::<usize>();
let total_chars = table_chars.saturating_add(paragraph_chars);
let table_share = if total_chars == 0 {
0.0
} else {
table_chars as f64 / total_chars as f64
};
if table_share < TABLE_DOMINANT_MIN_VISIBLE_CHAR_SHARE {
continue;
}
let table_bboxes = page_tables
.iter()
.filter_map(|table| table.bounding_box.as_ref())
.collect::<Vec<_>>();
if table_bboxes.is_empty() {
continue;
}
let before = paragraphs.len();
paragraphs.retain(|paragraph| !is_table_crop_spill(paragraph, &table_bboxes));
tracing::debug!(
page = page_number,
body_rows,
table_share,
removed = before.saturating_sub(paragraphs.len()),
"table-dominant paragraph spill cleanup"
);
}
}
fn visible_char_count(text: &str) -> usize {
text.chars().filter(|character| !character.is_whitespace()).count()
}
fn is_table_crop_spill(paragraph: &PdfParagraph, table_bboxes: &[&crate::types::BoundingBox]) -> bool {
if is_preserved_table_page_annotation(paragraph) {
return false;
}
let Some(paragraph_bbox) = paragraph_geometry_bbox(paragraph) else {
return false;
};
if !table_bboxes.iter().any(|table_bbox| {
table_paragraph_overlap_fraction(paragraph_bbox, table_bbox) >= TABLE_SPILL_MIN_PARAGRAPH_OVERLAP
}) {
return false;
}
let text = paragraph_text_raw(paragraph);
let alphabetic_chars = text.chars().filter(|character| character.is_alphabetic()).count();
let numeric_chars = text.chars().filter(|character| character.is_numeric()).count();
paragraph.word_count >= 13 || alphabetic_chars < 5 || numeric_chars >= alphabetic_chars
}
fn table_paragraph_overlap_fraction(
paragraph_bbox: (f32, f32, f32, f32),
table_bbox: &crate::types::BoundingBox,
) -> f64 {
let (raw_left, raw_bottom, raw_right, raw_top) = paragraph_bbox;
let paragraph_left = raw_left.min(raw_right) as f64;
let paragraph_right = raw_left.max(raw_right) as f64;
let paragraph_bottom = raw_bottom.min(raw_top) as f64;
let paragraph_top = raw_bottom.max(raw_top) as f64;
let paragraph_area = (paragraph_right - paragraph_left) * (paragraph_top - paragraph_bottom);
if paragraph_area <= 0.0 {
return 0.0;
}
let intersection_width = (paragraph_right.min(table_bbox.x0.max(table_bbox.x1))
- paragraph_left.max(table_bbox.x0.min(table_bbox.x1)))
.max(0.0);
let intersection_height = (paragraph_top.min(table_bbox.y0.max(table_bbox.y1))
- paragraph_bottom.max(table_bbox.y0.min(table_bbox.y1)))
.max(0.0);
intersection_width * intersection_height / paragraph_area
}
fn is_preserved_table_page_annotation(paragraph: &PdfParagraph) -> bool {
if paragraph.heading_level.is_some()
|| paragraph.caption_for.is_some()
|| paragraph.is_list_item
|| paragraph.is_code_block
|| paragraph.is_formula
|| matches!(
paragraph.layout_class,
Some(
super::types::LayoutHintClass::Title
| super::types::LayoutHintClass::SectionHeader
| super::types::LayoutHintClass::Caption
| super::types::LayoutHintClass::Footnote
| super::types::LayoutHintClass::PageHeader
| super::types::LayoutHintClass::PageFooter
| super::types::LayoutHintClass::ListItem
| super::types::LayoutHintClass::Code
| super::types::LayoutHintClass::Formula
| super::types::LayoutHintClass::DocumentIndex
| super::types::LayoutHintClass::Form
| super::types::LayoutHintClass::KeyValueRegion
)
)
{
return true;
}
let text = paragraph_text_raw(paragraph);
let label = text
.trim_start()
.split_once(char::is_whitespace)
.map_or(text.trim(), |(first, _)| first)
.trim_end_matches([':', '.'])
.to_ascii_lowercase();
matches!(label.as_str(), "note" | "notes" | "source" | "sources" | "table")
}
fn is_wrapper_layout_hint(hint: &LayoutHint) -> bool {
hint.class_name.is_wrapper()
}
fn regular_layout_hints(hints: &[LayoutHint]) -> Vec<LayoutHint> {
hints
.iter()
.filter(|hint| !is_wrapper_layout_hint(hint))
.cloned()
.collect()
}
fn segments_to_paragraphs(
segments: Vec<SegmentData>,
heading_map: &[(f32, Option<u8>)],
paragraph_gap_ys: &[f32],
witnesses: &TextRepairWitnesses,
) -> Vec<PdfParagraph> {
let segments = order_segments_in_reading_frames(segments);
let mut paragraphs = blocks_to_paragraphs(segments, heading_map, paragraph_gap_ys);
apply_text_repair_to_structure_tree_paragraphs(&mut paragraphs, true, witnesses);
reattach_detached_list_markers(&mut paragraphs, DetachedMarkerFrame::Native);
merge_continuation_paragraphs(&mut paragraphs);
synchronize_paragraph_text_metadata(&mut paragraphs);
paragraphs
}
const REATTACH_DETACHED_LIST_MARKERS: bool = true;
const SUPPRESS_LOWERCASE_START_HEADINGS: bool = true;
const DETACHED_MARKER_BASELINE_TOLERANCE_FONT_FACTOR: f32 = 0.6;
const DETACHED_MARKER_MAX_INDENT_FONT_FACTOR: f32 = 6.0;
const DETACHED_MARKER_MAX_OVERLAP_FONT_FACTOR: f32 = 0.5;
pub(super) const DETACHED_MARKER_MAX_LOOKAHEAD: usize = 8;
pub(super) const DETACHED_MARKER_MIN_BODY_WORDS: usize = 2;
const EXCLUDE_AMBIGUOUS_DETACHED_MARKERS: bool = true;
fn is_ambiguous_detached_marker(text: &str) -> bool {
let t = text.trim();
if t == "*" {
return true;
}
if let Some(inner) = t.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')) {
return !inner.is_empty() && inner.chars().all(|character| character.is_ascii_digit());
}
false
}
pub(super) fn is_bare_detached_list_marker(text: &str) -> bool {
is_bare_list_marker(text) && !(EXCLUDE_AMBIGUOUS_DETACHED_MARKERS && is_ambiguous_detached_marker(text))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum DetachedMarkerFrame {
Native,
#[cfg_attr(not(any(feature = "ocr", feature = "ocr-pipeline")), allow(dead_code))]
OcrOnPage(u32),
}
impl DetachedMarkerFrame {
fn baseline(self, segment: &SegmentData) -> f32 {
match self {
Self::Native => segment.upright_baseline(),
Self::OcrOnPage(270) => segment.x + segment.width,
Self::OcrOnPage(90) => segment.x,
Self::OcrOnPage(_) => segment.baseline_y,
}
}
fn advance_extent(self, segment: &SegmentData) -> (f32, f32) {
match self {
Self::Native => segment.upright_advance_extent(),
Self::OcrOnPage(270) => (-(segment.y + segment.height), -segment.y),
Self::OcrOnPage(90) => (segment.y, segment.y + segment.height),
Self::OcrOnPage(_) => (segment.x, segment.x + segment.width),
}
}
}
pub(super) fn reattach_detached_list_markers(paragraphs: &mut Vec<PdfParagraph>, frame: DetachedMarkerFrame) {
if !REATTACH_DETACHED_LIST_MARKERS || paragraphs.len() < 2 {
return;
}
let mut consumed = vec![false; paragraphs.len()];
let mut pairs: Vec<(usize, usize)> = Vec::new();
for marker_index in 0..paragraphs.len() {
if consumed[marker_index] {
continue;
}
let Some(marker) = detached_list_marker(¶graphs[marker_index]) else {
continue;
};
let limit = (marker_index + 1 + DETACHED_MARKER_MAX_LOOKAHEAD).min(paragraphs.len());
let body_index = (marker_index + 1..limit).find(|&candidate| {
!consumed[candidate] && accepts_detached_list_marker(¶graphs[candidate], &marker, frame)
});
let Some(body_index) = body_index else {
continue;
};
consumed[marker_index] = true;
consumed[body_index] = true;
pairs.push((marker_index, body_index));
}
if pairs.is_empty() {
return;
}
for (marker_index, body_index) in &pairs {
let Some(marker_segment) = paragraphs[*marker_index]
.lines
.first()
.and_then(|line| line.segments.first())
.cloned()
else {
continue;
};
let marker_bbox = paragraphs[*marker_index].block_bbox;
let body = &mut paragraphs[*body_index];
if let Some(line) = body.lines.first_mut() {
line.segments.insert(0, marker_segment);
}
body.is_list_item = true;
body.block_bbox = match (body.block_bbox, marker_bbox) {
(Some(body_bbox), Some(marker_bbox)) => Some((
body_bbox.0.min(marker_bbox.0),
body_bbox.1.min(marker_bbox.1),
body_bbox.2.max(marker_bbox.2),
body_bbox.3.max(marker_bbox.3),
)),
(bbox @ Some(_), None) | (None, bbox @ Some(_)) => bbox,
(None, None) => None,
};
body.text.clear();
body.word_count = PdfParagraph::compute_word_count("", &body.lines);
}
let mut index = 0usize;
paragraphs.retain(|_| {
let keep = !pairs.iter().any(|(marker_index, _)| *marker_index == index);
index += 1;
keep
});
}
fn detached_list_marker(paragraph: &PdfParagraph) -> Option<SegmentData> {
if paragraph.heading_level.is_some() || paragraph.is_list_item || paragraph.is_code_block || paragraph.is_formula {
return None;
}
let [line] = paragraph.lines.as_slice() else {
return None;
};
let [segment] = line.segments.as_slice() else {
return None;
};
if !is_bare_detached_list_marker(&segment.text) {
return None;
}
let geometry_is_usable = segment.x.is_finite()
&& segment.width.is_finite()
&& segment.width >= 0.0
&& segment.font_size.is_finite()
&& segment.font_size > 0.0
&& segment.upright_baseline().is_finite();
geometry_is_usable.then(|| segment.clone())
}
pub(super) fn accepts_detached_list_marker(
paragraph: &PdfParagraph,
marker: &SegmentData,
frame: DetachedMarkerFrame,
) -> bool {
if paragraph.heading_level.is_some()
|| paragraph.is_list_item
|| paragraph.is_code_block
|| paragraph.is_formula
|| paragraph.is_page_furniture
{
return false;
}
let Some(first_line) = paragraph.lines.first() else {
return false;
};
if first_line.segments.is_empty() {
return false;
}
let first_line_text = first_line
.segments
.iter()
.map(|segment| segment.text.trim())
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join(" ");
if looks_like_list_item(&first_line_text) || is_bare_list_marker(&first_line_text) {
return false;
}
let body_words = paragraph
.lines
.iter()
.flat_map(|line| line.segments.iter())
.flat_map(|segment| segment.text.split_whitespace())
.count();
if body_words < DETACHED_MARKER_MIN_BODY_WORDS {
return false;
}
let Some(anchor) = first_line.segments.first() else {
return false;
};
if !anchor.has_same_rotation(marker) {
return false;
}
let font_size = anchor.font_size.max(marker.font_size);
if !font_size.is_finite() || font_size <= 0.0 {
return false;
}
let baseline_delta = (frame.baseline(anchor) - frame.baseline(marker)).abs();
if !baseline_delta.is_finite() || baseline_delta > font_size * DETACHED_MARKER_BASELINE_TOLERANCE_FONT_FACTOR {
return false;
}
let body_left = first_line
.segments
.iter()
.map(|segment| frame.advance_extent(segment).0)
.fold(f32::INFINITY, f32::min);
let (marker_start, marker_end) = frame.advance_extent(marker);
if !body_left.is_finite() || !marker_start.is_finite() || !marker_end.is_finite() {
return false;
}
let indent = body_left - marker_end;
body_left > marker_start
&& indent >= -(font_size * DETACHED_MARKER_MAX_OVERLAP_FONT_FACTOR)
&& indent <= font_size * DETACHED_MARKER_MAX_INDENT_FONT_FACTOR
}
fn order_segments_in_reading_frames(segments: Vec<SegmentData>) -> Vec<SegmentData> {
if segments.iter().all(SegmentData::is_unrotated) {
return segments;
}
let mut groups: Vec<Vec<SegmentData>> = Vec::new();
for segment in segments {
match groups.last_mut() {
Some(group) if group[0].has_same_rotation(&segment) => group.push(segment),
_ => groups.push(vec![segment]),
}
}
groups
.into_iter()
.flat_map(|group| {
if group[0].is_unrotated() {
group
} else {
order_rotated_segment_group(group)
}
})
.collect()
}
fn order_rotated_segment_group(mut segments: Vec<SegmentData>) -> Vec<SegmentData> {
segments.sort_by(|first, second| {
second
.upright_baseline()
.total_cmp(&first.upright_baseline())
.then_with(|| {
first
.upright_advance_extent()
.0
.total_cmp(&second.upright_advance_extent().0)
})
});
let mut visual_lines: Vec<Vec<SegmentData>> = Vec::new();
for segment in segments {
let belongs_to_last_line = visual_lines.last().is_some_and(|line| {
let anchor = &line[0];
let tolerance = anchor.height.max(segment.height).max(anchor.font_size * 0.5) * 0.5;
(anchor.upright_baseline() - segment.upright_baseline()).abs() <= tolerance
});
if belongs_to_last_line {
if let Some(line) = visual_lines.last_mut() {
line.push(segment);
}
} else {
visual_lines.push(vec![segment]);
}
}
for line in &mut visual_lines {
line.sort_by(|first, second| {
first
.upright_advance_extent()
.0
.total_cmp(&second.upright_advance_extent().0)
});
}
visual_lines.into_iter().flatten().collect()
}
#[cfg(feature = "layout-detection")]
fn wrapper_ownership_by_hint(
hints: &[LayoutHint],
validations: &[super::regions::layout_validation::RegionValidation],
) -> Vec<bool> {
hints
.iter()
.enumerate()
.map(|(index, hint)| {
!is_wrapper_layout_hint(hint)
|| !matches!(
validations.get(index),
Some(super::regions::layout_validation::RegionValidation::Empty)
)
})
.collect()
}
#[cfg(feature = "layout-detection")]
struct LayoutParagraphContext<'a> {
heading_map: &'a [(f32, Option<u8>)],
paragraph_gap_ys: &'a [f32],
doc_body_font_size: Option<f32>,
include_headers: bool,
include_footers: bool,
include_footnotes: bool,
page_width_pts: Option<f32>,
apply_layout_overrides: bool,
witnesses: &'a TextRepairWitnesses,
}
#[cfg(feature = "layout-detection")]
struct NativeLayoutProjection {
groups: Vec<crate::extractors::pdf::reading_order::LayoutSegmentGroup>,
group_bounds: Vec<Option<(f32, f32, f32, f32)>>,
classification_hints: Vec<LayoutHint>,
}
#[cfg(feature = "layout-detection")]
fn process_layout_segment_groups(
segments: Vec<SegmentData>,
hints: &[LayoutHint],
wrapper_ownership: &[bool],
context: LayoutParagraphContext<'_>,
) -> Vec<PdfParagraph> {
let no_reorder = super::layout_debug::layout_debug_flags().no_reorder;
let groups = crate::extractors::pdf::reading_order::plan_segment_groups_by_layout(
&segments,
hints,
wrapper_ownership,
no_reorder,
context.page_width_pts,
);
if matches!(groups.as_slice(), [group] if group.hint_indices.is_empty() && group.region_path.is_none()) {
return segments_to_paragraphs(
segments,
context.heading_map,
context.paragraph_gap_ys,
context.witnesses,
);
}
if !context.apply_layout_overrides {
let group_bounds = layout_group_bounds(&groups, &segments);
let mut paragraphs = segments_to_paragraphs(
segments,
context.heading_map,
context.paragraph_gap_ys,
context.witnesses,
);
assign_native_paragraph_layout(&mut paragraphs, &groups, &group_bounds);
let classification_hints = regular_layout_hints(hints);
super::layout_classify::annotate_layout_classes(&mut paragraphs, &classification_hints, 0.5, 0.2);
return paragraphs;
}
let mut slots = segments.into_iter().map(Some).collect::<Vec<_>>();
let mut paragraphs = Vec::new();
for group in groups {
let region_path = group.region_path;
let group_segments = group
.segment_indices
.into_iter()
.filter_map(|index| slots.get_mut(index).and_then(Option::take))
.collect::<Vec<_>>();
if group_segments.is_empty() {
continue;
}
let gap_ys = compute_paragraph_gap_ys(&group_segments);
let mut group_paragraphs =
segments_to_paragraphs(group_segments, context.heading_map, &gap_ys, context.witnesses);
let group_hints = group
.hint_indices
.into_iter()
.filter_map(|index| hints.get(index).cloned())
.collect::<Vec<_>>();
if context.apply_layout_overrides {
super::layout_classify::apply_layout_overrides(
&mut group_paragraphs,
&group_hints,
0.5,
0.2,
context.doc_body_font_size,
);
un_mark_layout_furniture_per_config(
&mut group_paragraphs,
context.include_headers,
context.include_footers,
context.include_footnotes,
);
} else {
super::layout_classify::annotate_layout_classes(&mut group_paragraphs, &group_hints, 0.5, 0.2);
}
for paragraph in &mut group_paragraphs {
paragraph.layout_region_path = region_path;
}
paragraphs.extend(group_paragraphs);
}
let leftovers = slots.into_iter().flatten().collect::<Vec<_>>();
if !leftovers.is_empty() {
tracing::warn!(
segments = leftovers.len(),
"layout region plan omitted segments; appending an unsorted fallback group"
);
let gap_ys = compute_paragraph_gap_ys(&leftovers);
paragraphs.extend(segments_to_paragraphs(
leftovers,
context.heading_map,
&gap_ys,
context.witnesses,
));
}
paragraphs
}
#[cfg(feature = "layout-detection")]
fn assign_native_paragraph_layout(
paragraphs: &mut Vec<PdfParagraph>,
groups: &[crate::extractors::pdf::reading_order::LayoutSegmentGroup],
group_bounds: &[Option<(f32, f32, f32, f32)>],
) {
for paragraph in paragraphs {
let group_rank = paragraph_geometry_bbox(paragraph).and_then(|paragraph_bbox| {
groups
.iter()
.enumerate()
.filter_map(|(group_index, _)| {
let overlap = group_bounds
.get(group_index)
.and_then(|bounds| *bounds)
.map_or(0.0, |bounds| rectangle_overlap_area(paragraph_bbox, bounds));
(overlap > 0.0).then_some((group_index, overlap))
})
.max_by(|left, right| left.1.total_cmp(&right.1).then_with(|| right.0.cmp(&left.0)))
.map(|(group_index, _)| group_index)
});
let Some(group_index) = group_rank else {
continue;
};
let Some(mut path) = groups[group_index].region_path else {
continue;
};
let original_root_id = path.root.id;
let root_order = groups
.iter()
.position(|group| {
group
.region_path
.is_some_and(|candidate| candidate.root.id == original_root_id)
})
.unwrap_or(group_index);
path.root.id = root_order;
if let Some(child) = &mut path.child {
child.id = group_index;
}
paragraph.layout_region_path = Some(path);
}
}
fn reorder_pages_by_layout_region(pages: &mut [Vec<PdfParagraph>]) {
for page in pages {
page.sort_by_key(|paragraph| {
paragraph
.layout_region_path
.map(|path| path.child.map_or(path.root.id, |child| child.id))
.unwrap_or(usize::MAX)
});
}
}
fn paragraph_geometry_bbox(paragraph: &PdfParagraph) -> Option<(f32, f32, f32, f32)> {
if let Some(block_bbox) = paragraph.block_bbox {
return Some(block_bbox);
}
let mut segments = paragraph.lines.iter().flat_map(|line| line.segments.iter());
let first = segments.next()?;
let mut bounds = (
first.x,
first.y.min(first.baseline_y),
first.x + first.width,
(first.y + first.height).max(first.baseline_y + first.height),
);
for segment in segments {
bounds.0 = bounds.0.min(segment.x);
bounds.1 = bounds.1.min(segment.y.min(segment.baseline_y));
bounds.2 = bounds.2.max(segment.x + segment.width);
bounds.3 = bounds
.3
.max((segment.y + segment.height).max(segment.baseline_y + segment.height));
}
Some(bounds)
}
#[cfg(feature = "layout-detection")]
fn layout_group_bounds(
groups: &[crate::extractors::pdf::reading_order::LayoutSegmentGroup],
segments: &[SegmentData],
) -> Vec<Option<(f32, f32, f32, f32)>> {
groups
.iter()
.map(|group| {
let mut group_segments = group.segment_indices.iter().filter_map(|index| segments.get(*index));
let first = group_segments.next()?;
let mut bounds = (
first.x,
first.y.min(first.baseline_y),
first.x + first.width,
(first.y + first.height).max(first.baseline_y + first.height),
);
for segment in group_segments {
bounds.0 = bounds.0.min(segment.x);
bounds.1 = bounds.1.min(segment.y.min(segment.baseline_y));
bounds.2 = bounds.2.max(segment.x + segment.width);
bounds.3 = bounds
.3
.max((segment.y + segment.height).max(segment.baseline_y + segment.height));
}
Some(bounds)
})
.collect()
}
#[cfg(feature = "layout-detection")]
fn rectangle_overlap_area(left: (f32, f32, f32, f32), right: (f32, f32, f32, f32)) -> f32 {
let width = left.2.min(right.2) - left.0.max(right.0);
let height = left.3.min(right.3) - left.1.max(right.1);
width.max(0.0) * height.max(0.0)
}
const PARAGRAPH_GAP_HEIGHT_FACTOR: f32 = 1.5;
const PARAGRAPH_BREAK_LEADING_MULTIPLE: f32 = 1.5;
const INLINE_STYLE_BASELINE_TOLERANCE: f32 = 0.5;
pub(crate) const SCRIPT_RUN_BASE_LOOKBACK: usize = 4;
const INLINE_STYLE_MAX_FORWARD_GAP_FONT_FACTOR: f32 = 1.0;
const INLINE_FONT_SIZE_MAX_FORWARD_GAP_FONT_FACTOR: f32 = 1.5;
const INLINE_STYLE_MAX_OVERLAP_FONT_FACTOR: f32 = 0.15;
const HEADING_WRAP_RIGHT_EDGE_TOLERANCE_FONT_FACTOR: f32 = 2.0;
const HEADING_HANGING_INDENT_LEFT_EDGE_TOLERANCE_FONT_FACTOR: f32 = 0.5;
const HEADING_CONTINUATION_FONT_SIZE_TOLERANCE_PT: f32 = 1.5;
const HEADING_WRAP_INTERWORD_SPACE_FONT_FACTOR: f32 = 0.25;
const HEADING_CONTINUATION_RUN_PITCH_TOLERANCE_FACTOR: f32 = 0.25;
fn compute_paragraph_gap_ys(segments: &[SegmentData]) -> Vec<f32> {
if segments.len() < 2 {
return Vec::new();
}
if segments.iter().all(SegmentData::is_unrotated) {
return compute_paragraph_gap_ys_in_shared_frame(segments);
}
let mut gaps = Vec::new();
let mut group_start = 0;
for index in 1..=segments.len() {
let ends_group = index == segments.len() || !segments[index - 1].has_same_rotation(&segments[index]);
if ends_group {
gaps.extend(compute_paragraph_gap_ys_in_shared_frame(&segments[group_start..index]));
group_start = index;
}
}
gaps
}
struct LineBand {
top: f32,
bottom: f32,
height: f32,
monospace: bool,
anchor_y: f32,
}
fn compute_paragraph_gap_ys_in_shared_frame(segments: &[SegmentData]) -> Vec<f32> {
if segments.len() < 2 {
return Vec::new();
}
let mut order: Vec<usize> = (0..segments.len()).collect();
order.sort_by(|&a, &b| {
paragraph_gap_axis(&segments[b])
.partial_cmp(¶graph_gap_axis(&segments[a]))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut lines: Vec<LineBand> = Vec::new();
for &i in &order {
let seg = &segments[i];
let (bottom, top) = if seg.is_unrotated() {
(seg.y, seg.y + seg.height)
} else {
seg.upright_cross_extent()
};
let baseline = paragraph_gap_axis(seg);
let tolerance = (seg.height * 0.5).max(1.0);
match lines.last_mut() {
Some(line) if (baseline - line.anchor_y).abs() <= tolerance => {
line.top = line.top.max(top);
line.bottom = line.bottom.min(bottom);
line.height = line.height.max(seg.height);
line.monospace &= seg.is_monospace;
}
_ => lines.push(LineBand {
top,
bottom,
height: seg.height,
monospace: seg.is_monospace,
anchor_y: baseline,
}),
}
}
if lines.len() < 2 {
return Vec::new();
}
let mut heights: Vec<f32> = lines.iter().map(|l| l.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_height = heights[heights.len() / 2];
let gap_threshold = median_height * PARAGRAPH_GAP_HEIGHT_FACTOR;
let advance_threshold = body_leading(&lines, median_height) * PARAGRAPH_BREAK_LEADING_MULTIPLE;
let mut gap_ys = Vec::new();
for pair in lines.windows(2) {
let gap = pair[0].bottom - pair[1].top;
let advance = pair[0].anchor_y - pair[1].anchor_y;
if (gap > gap_threshold || advance > advance_threshold) && !(pair[0].monospace && pair[1].monospace) {
gap_ys.push((pair[0].bottom + pair[1].top) / 2.0);
}
}
gap_ys
}
fn body_leading(lines: &[LineBand], median_height: f32) -> f32 {
let tightest = lines
.windows(2)
.map(|pair| pair[0].anchor_y - pair[1].anchor_y)
.filter(|advance| advance.is_finite() && *advance > 0.0)
.fold(f32::INFINITY, f32::min);
if tightest.is_finite() {
tightest.max(median_height)
} else {
median_height
}
}
fn paragraph_gap_axis(segment: &SegmentData) -> f32 {
if segment.is_unrotated() {
segment.y
} else {
segment.upright_baseline()
}
}
fn visual_line_texts(lines: &[SegmentData]) -> Vec<String> {
let mut texts = vec![String::new(); lines.len()];
let mut start = 0usize;
while start < lines.len() {
let mut end = start + 1;
while end < lines.len()
&& lines[end].has_same_rotation(&lines[end - 1])
&& (lines[end].upright_baseline() - lines[end - 1].upright_baseline()).abs()
<= INLINE_STYLE_BASELINE_TOLERANCE
{
end += 1;
}
let joined = lines[start..end]
.iter()
.map(|segment| segment.text.trim())
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join(" ");
for slot in &mut texts[start..end] {
slot.clone_from(&joined);
}
start = end;
}
texts
}
fn blocks_to_paragraphs(
lines: Vec<SegmentData>,
heading_map: &[(f32, Option<u8>)],
paragraph_gap_ys: &[f32],
) -> Vec<PdfParagraph> {
if lines.is_empty() {
return Vec::new();
}
let gap_info = super::classify::precompute_gap_info(heading_map);
let visual_line_texts = visual_line_texts(&lines);
let mut paragraphs: Vec<PdfParagraph> = Vec::new();
let mut current_lines: Vec<&SegmentData> = Vec::new();
let mut current_is_single_visual_line = true;
let mut prev_idx = 0usize;
for (line_idx, line) in lines.iter().enumerate() {
let should_break = if current_lines.is_empty() {
false
} else {
let prev = current_lines.last().unwrap();
let font_change = (line.font_size - prev.font_size).abs() > 1.5
&& !is_inline_style_transition(
current_is_single_visual_line,
prev,
line,
INLINE_FONT_SIZE_MAX_FORWARD_GAP_FONT_FACTOR,
)
&& !current_lines
.iter()
.rev()
.take(SCRIPT_RUN_BASE_LOOKBACK)
.any(|candidate| is_script_run_of(candidate, line));
let role_change = line.assigned_role != prev.assigned_role;
let bold_change = line.is_bold != prev.is_bold
&& !is_inline_style_transition(
current_is_single_visual_line,
prev,
line,
INLINE_STYLE_MAX_FORWARD_GAP_FONT_FACTOR,
);
let rotation_change = !line.has_same_rotation(prev);
let starts_new_line = rotation_change
|| (line.upright_baseline() - prev.upright_baseline()).abs() > INLINE_STYLE_BASELINE_TOLERANCE;
let has_same_line_follower = lines.get(line_idx + 1).is_some_and(|next| {
next.has_same_rotation(line)
&& (next.upright_baseline() - line.upright_baseline()).abs() <= INLINE_STYLE_BASELINE_TOLERANCE
});
let is_list = starts_new_line
&& (looks_like_list_item(&line.text) || (has_same_line_follower && is_bare_list_marker(&line.text)));
let starts_section =
starts_new_line && super::classify::is_numbered_section_heading(&visual_line_texts[line_idx]);
let heading_wrap_chain_open = !current_is_single_visual_line
&& current_lines
.first()
.is_some_and(|first| super::classify::is_numbered_section_heading(first.text.trim()));
let heading_continuation_accepted = current_lines.first().is_some_and(|heading_start| {
(current_is_single_visual_line
&& super::classify::is_numbered_section_heading(&visual_line_texts[prev_idx])
&& (heading_wraps_onto(prev, line)
|| heading_continuation_at_margin(heading_start, prev, line, &lines, line_idx)))
|| (heading_wrap_chain_open
&& heading_continuation_at_margin(heading_start, prev, line, &lines, line_idx))
|| heading_continuation_is_hanging_indent(heading_start, prev, line)
});
let follows_section = starts_new_line
&& ((current_is_single_visual_line
&& super::classify::is_numbered_section_heading(&visual_line_texts[prev_idx])
&& !heading_continuation_accepted)
|| heading_wrap_chain_open)
&& !heading_continuation_accepted;
let crossed_gap = !heading_continuation_accepted
&& paragraph_gap_ys.iter().any(|&gap_y| {
let previous_baseline = prev.upright_baseline();
let current_baseline = line.upright_baseline();
let (upper, lower) = if previous_baseline > current_baseline {
(previous_baseline, current_baseline)
} else {
(current_baseline, previous_baseline)
};
gap_y < upper && gap_y > lower
});
rotation_change
|| font_change
|| role_change
|| bold_change
|| is_list
|| starts_section
|| follows_section
|| crossed_gap
};
if should_break && !current_lines.is_empty() {
if let Some(para) = finalize_paragraph(¤t_lines, heading_map, &gap_info) {
paragraphs.push(para);
}
current_lines.clear();
current_is_single_visual_line = true;
}
if let Some(first) = current_lines.first() {
current_is_single_visual_line &= line.has_same_rotation(first)
&& (line.upright_baseline() - first.upright_baseline()).abs() <= INLINE_STYLE_BASELINE_TOLERANCE;
}
current_lines.push(line);
prev_idx = line_idx;
}
if !current_lines.is_empty()
&& let Some(para) = finalize_paragraph(¤t_lines, heading_map, &gap_info)
{
paragraphs.push(para);
}
tracing::debug!(
input_lines = lines.len(),
output_paragraphs = paragraphs.len(),
headings = paragraphs.iter().filter(|p| p.heading_level.is_some()).count(),
lists = paragraphs.iter().filter(|p| p.is_list_item).count(),
"blocks_to_paragraphs complete"
);
paragraphs
}
fn is_inline_style_transition(
current_is_single_visual_line: bool,
previous: &SegmentData,
next: &SegmentData,
max_forward_gap_font_factor: f32,
) -> bool {
if previous.is_monospace || next.is_monospace || previous.assigned_role != next.assigned_role {
return false;
}
if !previous.has_same_rotation(next) {
return false;
}
if !previous.font_size.is_finite()
|| !next.font_size.is_finite()
|| previous.font_size <= 0.0
|| next.font_size <= 0.0
|| !previous.upright_baseline().is_finite()
|| !next.upright_baseline().is_finite()
|| !previous.x.is_finite()
|| !next.x.is_finite()
|| !previous.width.is_finite()
|| !next.width.is_finite()
|| previous.width < 0.0
|| next.width < 0.0
{
return false;
}
let font_size = previous.font_size.max(next.font_size);
let baseline_delta = (next.upright_baseline() - previous.upright_baseline()).abs();
let (previous_start, previous_end) = previous.upright_advance_extent();
let (next_start, _) = next.upright_advance_extent();
let advance_gap = next_start - previous_end;
if is_script_run_of(previous, next) {
return true;
}
if !current_is_single_visual_line || baseline_delta > INLINE_STYLE_BASELINE_TOLERANCE {
return false;
}
next_start >= previous_start
&& advance_gap >= -(font_size * INLINE_STYLE_MAX_OVERLAP_FONT_FACTOR)
&& advance_gap <= font_size * max_forward_gap_font_factor
}
fn is_script_run_offset(previous: &SegmentData, next: &SegmentData, baseline_delta: f32, font_size: f32) -> bool {
let smaller_font_size = previous.font_size.min(next.font_size);
crate::script_run::is_script_run_baseline_offset(baseline_delta, font_size, smaller_font_size)
}
pub(crate) fn is_script_run_of(previous: &SegmentData, next: &SegmentData) -> bool {
if previous.is_monospace || next.is_monospace || previous.assigned_role != next.assigned_role {
return false;
}
if !previous.has_same_rotation(next) {
return false;
}
if !previous.font_size.is_finite()
|| !next.font_size.is_finite()
|| previous.font_size <= 0.0
|| next.font_size <= 0.0
|| !previous.upright_baseline().is_finite()
|| !next.upright_baseline().is_finite()
|| !previous.x.is_finite()
|| !next.x.is_finite()
|| !previous.width.is_finite()
|| !next.width.is_finite()
|| previous.width < 0.0
|| next.width < 0.0
{
return false;
}
let font_size = previous.font_size.max(next.font_size);
let baseline_delta = (next.upright_baseline() - previous.upright_baseline()).abs();
let (previous_start, previous_end) = previous.upright_advance_extent();
let (next_start, _) = next.upright_advance_extent();
is_script_run_offset(previous, next, baseline_delta, font_size)
&& crate::script_run::is_script_run_forward_gap(previous_start, previous_end, next_start, font_size)
}
pub(super) fn heading_wraps_onto(prev: &SegmentData, line: &SegmentData) -> bool {
if !prev.has_same_rotation(line) {
return false;
}
if !prev.font_size.is_finite() || !line.font_size.is_finite() {
return false;
}
let (_, prev_end) = prev.upright_advance_extent();
let (_, line_end) = line.upright_advance_extent();
if !prev_end.is_finite() || !line_end.is_finite() {
return false;
}
let tolerance = HEADING_WRAP_RIGHT_EDGE_TOLERANCE_FONT_FACTOR * prev.font_size.max(line.font_size).max(1.0);
(prev_end - line_end).abs() <= tolerance
}
pub(super) fn heading_continuation_is_hanging_indent(
heading_start: &SegmentData,
prev: &SegmentData,
line: &SegmentData,
) -> bool {
if !prev.has_same_rotation(line) || !prev.has_same_rotation(heading_start) {
return false;
}
if !prev.font_size.is_finite() || !line.font_size.is_finite() {
return false;
}
let (heading_left, _) = heading_start.upright_advance_extent();
let (prev_left, _) = prev.upright_advance_extent();
let (line_left, _) = line.upright_advance_extent();
if !heading_left.is_finite() || !prev_left.is_finite() || !line_left.is_finite() {
return false;
}
let tolerance =
HEADING_HANGING_INDENT_LEFT_EDGE_TOLERANCE_FONT_FACTOR * prev.font_size.max(line.font_size).max(1.0);
if prev_left - heading_left <= tolerance || (prev_left - line_left).abs() > tolerance {
return false;
}
let (_, line_end) = line.upright_advance_extent();
line_end.is_finite() && heading_fills_column(prev, line_end)
}
pub(super) fn heading_fills_column(prev: &SegmentData, next_right_edge: f32) -> bool {
if !prev.font_size.is_finite() || !next_right_edge.is_finite() {
return false;
}
let (_, prev_end) = prev.upright_advance_extent();
if !prev_end.is_finite() {
return false;
}
let tolerance = HEADING_WRAP_RIGHT_EDGE_TOLERANCE_FONT_FACTOR * prev.font_size.max(1.0);
prev_end >= next_right_edge - tolerance
}
fn heading_line_ran_out_of_room(prev: &SegmentData, continuation: &SegmentData, column_right_edge: f32) -> bool {
if heading_fills_column(prev, column_right_edge) {
return true;
}
if !column_right_edge.is_finite() || !prev.font_size.is_finite() {
return false;
}
let (_, prev_end) = prev.upright_advance_extent();
let Some(leading_word_width) = estimated_leading_word_width(continuation) else {
return false;
};
if !prev_end.is_finite() {
return false;
}
let word_space = HEADING_WRAP_INTERWORD_SPACE_FONT_FACTOR * prev.font_size.max(1.0);
prev_end + word_space + leading_word_width > column_right_edge
}
fn estimated_leading_word_width(segment: &SegmentData) -> Option<f32> {
if !segment.width.is_finite() || segment.width <= 0.0 {
return None;
}
let character_count = segment.text.chars().count();
if character_count == 0 {
return None;
}
let leading_word = segment.text.split_whitespace().next()?;
let mean_character_width = segment.width / character_count as f32;
Some(mean_character_width * leading_word.chars().count() as f32)
}
fn next_visual_line_right_edge(
lines: &[SegmentData],
after_idx: usize,
heading_pitch: f32,
) -> Option<(f32, &SegmentData)> {
let anchor = &lines[after_idx];
let (anchor_left, _) = anchor.upright_advance_extent();
let mut start = after_idx + 1;
while start < lines.len()
&& lines[start].has_same_rotation(anchor)
&& (lines[start].upright_baseline() - anchor.upright_baseline()).abs() <= INLINE_STYLE_BASELINE_TOLERANCE
{
start += 1;
}
let mut previous_baseline = anchor.upright_baseline();
let mut last_skipped = None;
loop {
let Some((end, left_edge, right_edge, leader)) = visual_line_group(lines, start) else {
return last_skipped;
};
let measured = right_edge.is_finite().then_some((right_edge, leader));
if !continues_heading_run(anchor, anchor_left, leader, left_edge, previous_baseline, heading_pitch) {
return measured;
}
last_skipped = measured.or(last_skipped);
previous_baseline = leader.upright_baseline();
start = end;
}
}
fn visual_line_group(lines: &[SegmentData], start: usize) -> Option<(usize, f32, f32, &SegmentData)> {
let leader = lines.get(start)?;
let mut right_edge = f32::NEG_INFINITY;
let mut left_edge = f32::INFINITY;
let mut end = start;
while end < lines.len()
&& lines[end].has_same_rotation(leader)
&& (lines[end].upright_baseline() - leader.upright_baseline()).abs() <= INLINE_STYLE_BASELINE_TOLERANCE
{
let (left, edge) = lines[end].upright_advance_extent();
if edge.is_finite() {
right_edge = right_edge.max(edge);
}
if left.is_finite() {
left_edge = left_edge.min(left);
}
end += 1;
}
Some((end, left_edge, right_edge, leader))
}
fn continues_heading_run(
anchor: &SegmentData,
anchor_left: f32,
candidate: &SegmentData,
candidate_left: f32,
previous_baseline: f32,
heading_pitch: f32,
) -> bool {
if !anchor.has_same_rotation(candidate) || candidate.is_bold != anchor.is_bold {
return false;
}
if candidate.is_italic != anchor.is_italic {
return false;
}
if !anchor.font_size.is_finite() || !candidate.font_size.is_finite() {
return false;
}
if (candidate.font_size - anchor.font_size).abs() > HEADING_CONTINUATION_FONT_SIZE_TOLERANCE_PT {
return false;
}
if !anchor_left.is_finite() || !candidate_left.is_finite() {
return false;
}
let left_tolerance =
HEADING_HANGING_INDENT_LEFT_EDGE_TOLERANCE_FONT_FACTOR * anchor.font_size.max(candidate.font_size).max(1.0);
if (candidate_left - anchor_left).abs() > left_tolerance {
return false;
}
if !heading_pitch.is_finite() || heading_pitch <= 0.0 {
return false;
}
let advance = (previous_baseline - candidate.upright_baseline()).abs();
(advance - heading_pitch).abs() <= HEADING_CONTINUATION_RUN_PITCH_TOLERANCE_FACTOR * heading_pitch
}
fn heading_continuation_at_margin(
heading_start: &SegmentData,
prev: &SegmentData,
line: &SegmentData,
lines: &[SegmentData],
line_idx: usize,
) -> bool {
if !prev.has_same_rotation(line) || !prev.has_same_rotation(heading_start) {
return false;
}
if !prev.font_size.is_finite() || !line.font_size.is_finite() {
return false;
}
if (line.font_size - prev.font_size).abs() > HEADING_CONTINUATION_FONT_SIZE_TOLERANCE_PT
|| line.is_bold != prev.is_bold
{
return false;
}
let (heading_left, _) = heading_start.upright_advance_extent();
let (line_left, line_end) = line.upright_advance_extent();
if !heading_left.is_finite() || !line_left.is_finite() || !line_end.is_finite() {
return false;
}
let tolerance =
HEADING_HANGING_INDENT_LEFT_EDGE_TOLERANCE_FONT_FACTOR * prev.font_size.max(line.font_size).max(1.0);
if (line_left - heading_left).abs() > tolerance {
return false;
}
let heading_pitch = (prev.upright_baseline() - line.upright_baseline()).abs();
let Some((line_beneath_edge, beneath)) = next_visual_line_right_edge(lines, line_idx, heading_pitch) else {
return false;
};
let (_, prev_end) = prev.upright_advance_extent();
let column_right_edge = if prev_end.is_finite() {
line_beneath_edge.max(prev_end)
} else {
line_beneath_edge
};
if line_end > column_right_edge + tolerance {
return false;
}
if line.text.trim_start().chars().next().is_some_and(char::is_lowercase) {
return heading_line_ran_out_of_room(prev, line, column_right_edge);
}
heading_style_carries_capital_opener(prev, line, beneath) && heading_fills_column(prev, column_right_edge)
}
fn heading_style_carries_capital_opener(prev: &SegmentData, line: &SegmentData, beneath: &SegmentData) -> bool {
line.is_italic == prev.is_italic
&& beneath.is_italic != prev.is_italic
&& !matches!(
prev.text.trim_end().chars().last(),
Some('.' | '?' | '!' | '\u{3002}' | '\u{FF1F}' | '\u{FF01}')
)
}
fn reconstruct_pdf_lines(segments: &[&SegmentData]) -> Vec<super::types::PdfLine> {
const MAX_LINE_TOLERANCE_PT: f32 = 3.0;
const LINE_TOLERANCE_SCALE_FACTOR: f32 = 0.25;
fn finish_line(mut segments: Vec<SegmentData>, baseline_y: f32) -> super::types::PdfLine {
let contains_rtl = segments.iter().any(|segment| {
segment
.text
.chars()
.any(|character| xberg_native_pdf::text::is_rtl_text(character as u32))
});
if !contains_rtl {
segments.sort_by(|a, b| a.upright_advance_extent().0.total_cmp(&b.upright_advance_extent().0));
}
let dominant_font_size = segments.iter().map(|s| s.font_size).fold(0.0, |a, b| {
if a > 0.0 && b > a / 2.0 && b < a * 2.0 {
(a + b) / 2.0
} else {
a.max(b)
}
});
let is_bold = segments.iter().filter(|s| s.is_bold).count() > segments.len() / 2;
let is_monospace = segments.iter().all(|s| s.is_monospace);
super::types::PdfLine {
segments,
baseline_y,
dominant_font_size,
is_bold,
is_monospace,
}
}
if segments.is_empty() {
return Vec::new();
}
let mut lines: Vec<super::types::PdfLine> = Vec::new();
let mut current_baseline = segments[0].upright_baseline();
let mut current_rotation = segments[0].rotation_degrees;
let mut current_scale = segments[0].font_size.max(segments[0].height).abs();
let mut current_segments: Vec<SegmentData> = Vec::new();
for seg in segments {
let same_rotation = (seg.rotation_degrees - current_rotation).abs() <= f32::EPSILON;
let segment_baseline = seg.upright_baseline();
let segment_scale = seg.font_size.max(seg.height).abs();
let baseline_tolerance =
(current_scale.max(segment_scale) * LINE_TOLERANCE_SCALE_FACTOR).min(MAX_LINE_TOLERANCE_PT);
if !same_rotation || (segment_baseline - current_baseline).abs() > baseline_tolerance {
if !current_segments.is_empty() {
lines.push(finish_line(std::mem::take(&mut current_segments), current_baseline));
}
current_baseline = segment_baseline;
current_rotation = seg.rotation_degrees;
current_scale = segment_scale;
} else {
current_scale = current_scale.max(segment_scale);
}
current_segments.push((*seg).clone());
}
if !current_segments.is_empty() {
lines.push(finish_line(current_segments, current_baseline));
}
lines
}
fn paragraph_text(lines: &[&SegmentData]) -> String {
if lines.iter().all(|segment| segment.is_unrotated()) {
return lines
.iter()
.map(|segment| segment.text.as_str())
.collect::<Vec<_>>()
.join("\n");
}
let mut text = String::new();
let mut previous: Option<&SegmentData> = None;
for segment in lines {
if let Some(previous) = previous {
if !previous.has_same_rotation(segment) {
text.push_str("\n\n");
} else {
let same_line = (previous.upright_baseline() - segment.upright_baseline()).abs()
< previous.height.max(segment.height).max(segment.font_size * 0.5) * 0.5;
if same_line {
let previous_word = previous.text.split_whitespace().next_back().unwrap_or("");
let next_word = segment.text.split_whitespace().next().unwrap_or("");
if !text.ends_with(char::is_whitespace)
&& !segment.text.starts_with(char::is_whitespace)
&& segments_need_space(previous, previous_word, segment, next_word)
{
text.push(' ');
}
} else {
text.push('\n');
}
}
}
text.push_str(&segment.text);
previous = Some(segment);
}
text
}
fn finalize_paragraph(
lines: &[&SegmentData],
heading_map: &[(f32, Option<u8>)],
gap_info: &super::classify::GapInfo,
) -> Option<PdfParagraph> {
if lines.is_empty() {
return None;
}
let text = paragraph_text(lines);
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let first = lines[0];
let word_count = trimmed.split_whitespace().count();
let is_bold = lines.iter().filter(|l| l.is_bold).count() > lines.len() / 2;
let has_mixed_inline_styles = lines
.iter()
.skip(1)
.any(|line| line.is_bold != first.is_bold || line.is_italic != first.is_italic);
let reconstructed_lines = reconstruct_pdf_lines(lines);
let starts_with_split_list_marker = lines.get(1).is_some_and(|body| {
is_bare_list_marker(&first.text)
&& body.has_same_rotation(first)
&& (body.upright_baseline() - first.upright_baseline()).abs() <= INLINE_STYLE_BASELINE_TOLERANCE
&& !body.text.trim().is_empty()
});
let is_list_candidate = looks_like_list_item(trimmed) || starts_with_split_list_marker;
let structure_tree_role = {
let role_counts: std::collections::HashMap<u8, usize> =
lines
.iter()
.filter_map(|l| l.assigned_role)
.fold(std::collections::HashMap::new(), |mut acc, level| {
*acc.entry(level).or_default() += 1;
acc
});
role_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(level, _)| level)
};
if let Some(level) = structure_tree_role {
let para_text = trimmed.to_string();
let word_count = PdfParagraph::compute_word_count(¶_text, &reconstructed_lines);
return Some(PdfParagraph {
text: if has_mixed_inline_styles {
String::new()
} else {
para_text
},
lines: reconstructed_lines,
dominant_font_size: first.font_size,
heading_level: Some(level),
is_bold,
is_list_item: is_list_candidate,
is_code_block: first.is_monospace && lines.len() > 1,
is_formula: false,
is_page_furniture: false,
layout_class: None,
layout_region_path: None,
caption_for: None,
block_bbox: None,
word_count,
});
}
let page_number_like =
word_count <= MAX_PAGE_NUMBER_WORD_COUNT && super::page_number::classify_page_number_text(trimmed).is_some();
let mut heading_level = super::classify::find_heading_level(first.font_size, heading_map, gap_info);
if heading_level.is_some()
&& (word_count > 20
|| super::layout_classify::is_separator_text(trimmed)
|| page_number_like
|| super::classify::reads_as_body_content(trimmed, word_count)
|| (SUPPRESS_LOWERCASE_START_HEADINGS && super::classify::starts_with_lowercase_or_continuation(trimmed)))
{
heading_level = None;
}
let body_font_size = heading_map
.iter()
.find(|(_, level)| level.is_none())
.map(|(centroid, _)| *centroid)
.unwrap_or(0.0);
let clears_bold_font_gate = body_font_size > 0.0
&& first.font_size >= body_font_size * super::constants::MIN_HEADING_FONT_RATIO
&& first.font_size >= body_font_size + super::constants::MIN_HEADING_FONT_GAP;
if heading_level.is_none()
&& is_bold
&& clears_bold_font_gate
&& (1..=8).contains(&word_count)
&& lines.len() == 1
&& !trimmed.ends_with('.')
&& !trimmed.ends_with(':')
&& !trimmed.ends_with(',')
&& !trimmed.ends_with(';')
&& !trimmed.contains('@')
&& !trimmed.contains('(')
&& !trimmed.contains(',')
&& trimmed
.chars()
.next()
.is_some_and(|c| c.is_uppercase() || c.is_ascii_digit())
&& !super::layout_classify::is_separator_text(trimmed)
&& !super::regions::looks_like_figure_label(trimmed)
{
heading_level = Some(2);
}
if heading_level.is_none() {
let min_heading_threshold = body_font_size * super::constants::MIN_HEADING_FONT_RATIO;
if body_font_size > 0.0
&& first.font_size >= min_heading_threshold
&& word_count <= super::constants::MAX_BOLD_HEADING_WORD_COUNT
&& lines.len() <= 2
&& !trimmed.ends_with(':')
&& !trimmed.contains('@')
&& (super::classify::is_section_pattern(trimmed) || is_structural_heading_word(trimmed))
&& !super::layout_classify::is_separator_text(trimmed)
&& !super::regions::looks_like_figure_label(trimmed)
&& !is_list_candidate
&& !page_number_like
{
heading_level = Some(2);
}
}
let is_list_item = heading_level.is_none() && is_list_candidate;
let is_code_block =
heading_level.is_none() && !is_list_item && lines.iter().all(|l| l.is_monospace) && lines.len() >= 2;
tracing::debug!(
font_size = first.font_size,
is_bold,
word_count,
heading_level = ?heading_level,
is_list_item,
is_code_block,
page_number_like,
text_preview = %&trimmed.chars().take(60).collect::<String>(),
"classified paragraph"
);
let para_text = trimmed.to_string();
let word_count = PdfParagraph::compute_word_count(¶_text, &reconstructed_lines);
Some(PdfParagraph {
text: if has_mixed_inline_styles {
String::new()
} else {
para_text
},
lines: reconstructed_lines,
dominant_font_size: first.font_size,
heading_level,
is_bold,
is_list_item,
is_code_block,
is_formula: false,
is_page_furniture: false,
layout_class: None,
layout_region_path: None,
caption_for: None,
block_bbox: Some({
let left = lines.iter().map(|l| l.x).fold(f32::MAX, f32::min);
let bottom = lines.iter().map(|l| l.baseline_y).fold(f32::MAX, f32::min);
let right = lines.iter().map(|l| l.x + l.width).fold(f32::MIN, f32::max);
let top = lines.iter().map(|l| l.baseline_y + l.height).fold(f32::MIN, f32::max);
(left, bottom, right, top)
}),
word_count,
})
}
pub(super) fn is_bare_list_marker(text: &str) -> bool {
let t = text.trim();
if t.is_empty() || t.chars().count() > 5 {
return false;
}
if matches!(t, "•" | "·" | "◦" | "▪" | "–" | "—" | "-" | "*") {
return true;
}
super::list_marker::parse_ordered_list_marker(t).is_some_and(|marker| !marker.has_content)
}
pub(crate) fn looks_like_list_item(text: &str) -> bool {
let t = text.trim_start();
if t.starts_with('•') || t.starts_with('·') || t.starts_with('◦') || t.starts_with('▪') {
return true;
}
if let Some(rest) = t.strip_prefix('–').or_else(|| t.strip_prefix('—')) {
if !rest.starts_with(' ') && !rest.starts_with('\t') {
return false;
}
let body = rest.trim_start_matches([' ', '\t']);
return !body.is_empty() && !body.starts_with('\r') && !body.starts_with('\n');
}
if let Some(rest) = t.strip_prefix("- ") {
return rest.chars().next().is_some_and(|c| c.is_alphabetic());
}
if super::classify::is_numbered_section_heading(t) {
return false;
}
let Some(marker) = super::list_marker::parse_ordered_list_marker(t) else {
return false;
};
let Some(first_content_char) = t.get(marker.content_start..).and_then(|content| content.chars().next()) else {
return false;
};
marker.has_content
&& marker.has_separator
&& !is_probable_author_byline(t)
&& first_content_char.is_alphabetic()
&& !is_inline_parenthesized_quantity(t, &marker, first_content_char)
}
fn is_inline_parenthesized_quantity(
t: &str,
marker: &super::list_marker::OrderedListMarker,
first_content_char: char,
) -> bool {
if !t.starts_with('(') || marker.numeric_value.is_none() {
return false;
}
let separator_region = t.get(..marker.content_start).unwrap_or("");
if separator_region.contains(['\n', '\r']) {
return false;
}
!first_content_char.is_uppercase()
}
pub(super) fn is_probable_author_byline(text: &str) -> bool {
let mut chars = text.chars();
if !chars.next().is_some_and(|c| c.is_ascii_uppercase()) || chars.next() != Some('.') {
return false;
}
let remainder = chars.as_str().trim_start();
let Some((surname, remainder)) = remainder.split_once(char::is_whitespace) else {
return false;
};
surname.ends_with(',') && starts_with_author_initial_or_slash(remainder.trim_start())
}
fn starts_with_author_initial_or_slash(text: &str) -> bool {
if text.starts_with('/') {
return true;
}
let mut chars = text.chars().peekable();
let mut initials = 0;
while chars.peek().is_some_and(|c| c.is_ascii_uppercase()) {
chars.next();
if chars.next() != Some('.') {
return false;
}
initials += 1;
}
initials > 0 && chars.peek().is_some_and(|c| c.is_whitespace())
}
fn is_structural_heading_word(text: &str) -> bool {
let t = text.trim();
matches!(
t,
"Abstract"
| "References"
| "Appendix"
| "Acknowledgments"
| "Acknowledgements"
| "Conclusion"
| "Conclusions"
| "Bibliography"
| "Contents"
| "Index"
| "Glossary"
| "Summary"
| "Discussion"
| "Methods"
| "Results"
| "Methodology"
)
}
pub(crate) struct SegmentStructureConfig<'a> {
pub k_clusters: usize,
pub tables: &'a [crate::types::Table],
pub outline_entries: &'a [PdfOutlineEntry],
pub strip_repeating_text: bool,
pub include_headers: bool,
pub include_footers: bool,
pub include_footnotes: bool,
pub include_watermarks: bool,
pub used_structure_tree: bool,
pub image_positions: &'a [(u32, u32)],
pub images: Option<&'a [crate::types::ExtractedImage]>,
pub inject_placeholders: bool,
pub layout_hints: Option<&'a [Vec<LayoutHint>]>,
pub allow_single_column: bool,
pub cancel_token: Option<&'a crate::cancellation::CancellationToken>,
#[cfg(feature = "layout-detection")]
pub layout_images: Option<&'a [image::RgbImage]>,
#[cfg(feature = "layout-detection")]
pub layout_results: Option<&'a [super::types::PageLayoutResult]>,
#[cfg(feature = "layout-detection")]
pub table_model: crate::core::config::layout::TableModel,
#[cfg(feature = "layout-detection")]
pub table_overlap_preference: crate::core::config::layout::TableOverlapPreference,
#[cfg(feature = "layout-detection")]
pub acceleration: Option<&'a crate::core::config::acceleration::AccelerationConfig>,
#[cfg(feature = "layout-detection")]
pub session_thread_budget: usize,
}
#[cfg(feature = "layout-detection")]
fn slanet_variant_for_table_model(table_model: crate::core::config::layout::TableModel) -> Option<&'static str> {
use crate::core::config::layout::TableModel;
match table_model {
TableModel::SlanetWired | TableModel::SlanetAuto => Some("slanet_wired"),
TableModel::SlanetWireless => Some("slanet_wireless"),
TableModel::SlanetPlus => Some("slanet_plus"),
TableModel::Tatr | TableModel::Disabled => None,
}
}
pub(crate) fn extract_document_structure_from_segments(
mut all_page_segments: Vec<Vec<SegmentData>>,
config: SegmentStructureConfig<'_>,
) -> Result<crate::types::internal::InternalDocument> {
let SegmentStructureConfig {
k_clusters,
tables,
outline_entries,
strip_repeating_text,
include_headers,
include_footers,
include_footnotes,
include_watermarks,
used_structure_tree,
image_positions,
images,
inject_placeholders,
layout_hints,
allow_single_column,
cancel_token,
#[cfg(feature = "layout-detection")]
layout_images,
#[cfg(feature = "layout-detection")]
layout_results,
#[cfg(feature = "layout-detection")]
table_model,
#[cfg(feature = "layout-detection")]
table_overlap_preference,
#[cfg(feature = "layout-detection")]
acceleration,
#[cfg(feature = "layout-detection")]
session_thread_budget,
} = config;
let page_count = all_page_segments.len();
tracing::debug!(
page_count,
used_structure_tree,
"native structure pipeline: starting from pre-extracted segments"
);
let struct_tree_results: Vec<Option<Vec<PdfParagraph>>> = vec![None; page_count];
let heuristic_pages: Vec<usize> = (0..page_count).collect();
let (heading_map, doc_body_font_size) = if used_structure_tree {
let mut heading_map = build_heading_map_from_assigned_roles(&all_page_segments);
if !suppress_all_heading_roles_when_sparse_and_untrusted(&mut heading_map, &mut all_page_segments)
&& promote_untagged_document_title(&mut heading_map, &all_page_segments)
{
demote_assigned_roles(&mut all_page_segments);
}
let doc_body_font_size: Option<f32> = heading_map
.iter()
.find(|(_, level)| level.is_none())
.map(|(size, _)| *size);
tracing::debug!(
heading_map_len = heading_map.len(),
"native structure pipeline: heading map from structure tree"
);
(heading_map, doc_body_font_size)
} else {
let (heading_map, _struct_tree_needs_classify) =
build_heading_map(&all_page_segments, &struct_tree_results, &heuristic_pages, k_clusters)?;
let doc_body_font_size: Option<f32> = heading_map
.iter()
.find(|(_, level)| level.is_none())
.map(|(size, _)| *size);
(heading_map, doc_body_font_size)
};
let page_heights: Vec<f32> = all_page_segments
.iter()
.map(|segs| segs.iter().map(|s| s.y + s.height).fold(0.0_f32, f32::max).max(792.0))
.collect();
let mut layout_tables: Vec<crate::types::Table> = Vec::new();
if let Some(hints_pages) = layout_hints {
struct TablePageData {
page_idx: usize,
words: Vec<crate::pdf::table_reconstruct::HocrWord>,
page_height: f32,
}
let mut table_pages: Vec<TablePageData> = Vec::new();
let mut geometric_table_pages: Vec<(
usize,
Vec<crate::pdf::table_reconstruct::HocrWord>,
f32,
Vec<LayoutHint>,
)> = Vec::new();
#[allow(clippy::needless_range_loop)]
for page_idx in 0..page_count {
if cancel_token.is_some_and(|t| t.is_cancelled()) {
tracing::debug!(page_idx, "native structure pipeline: cancelled during table page prep");
break;
}
let Some(hints) = hints_pages.get(page_idx) else {
continue;
};
let ml_table_hints: Vec<&LayoutHint> = hints
.iter()
.filter(|h| h.class_name == super::types::LayoutHintClass::Table)
.collect();
let has_table_hint = !ml_table_hints.is_empty();
#[cfg(feature = "layout-detection")]
let page_height = layout_results
.and_then(|results| results.get(page_idx))
.map(|pr| pr.page_height_pts)
.unwrap_or(page_heights[page_idx]);
#[cfg(not(feature = "layout-detection"))]
let page_height = page_heights[page_idx];
let words = crate::pdf::table_reconstruct::segments_to_words(&all_page_segments[page_idx], page_height);
if words.is_empty() {
tracing::trace!(
page = page_idx,
"native layout table extraction: no words from segments, skipping"
);
continue;
}
let synthetic = super::regions::detect_geometric_table_hints(&words, page_height, &ml_table_hints);
if !synthetic.is_empty() {
tracing::debug!(
page = page_idx,
regions = synthetic.len(),
has_table_hint,
"geometric table fallback: synthesized Table region(s) outside existing ML hints"
);
geometric_table_pages.push((page_idx, words.clone(), page_height, synthetic));
}
if has_table_hint {
tracing::trace!(
page = page_idx,
word_count = words.len(),
page_height,
"native layout table extraction: page prepared"
);
table_pages.push(TablePageData {
page_idx,
words,
page_height,
});
}
}
#[cfg(feature = "layout-detection")]
{
use crate::core::config::layout::TableModel;
let use_model_inference = table_model != TableModel::Disabled;
let slanet_variant = slanet_variant_for_table_model(table_model);
let is_auto = table_model == TableModel::SlanetAuto;
let model_name = match table_model {
TableModel::Tatr => "TATR",
TableModel::SlanetWired | TableModel::SlanetWireless | TableModel::SlanetPlus => "SLANeXT",
TableModel::SlanetAuto => "SLANeXT (auto)",
TableModel::Disabled => "disabled",
};
let has_table_model = if use_model_inference {
let available = match table_model {
TableModel::Tatr => crate::layout::is_tatr_available(acceleration, session_thread_budget),
TableModel::SlanetWired
| TableModel::SlanetWireless
| TableModel::SlanetPlus
| TableModel::SlanetAuto => slanet_variant.is_some_and(|variant| {
crate::layout::is_slanet_available(variant, acceleration, session_thread_budget)
}),
TableModel::Disabled => false,
};
if !available && !table_pages.is_empty() {
return Err(crate::pdf::error::PdfError::TextExtractionFailed(format!(
"Layout detection found table regions but {model_name} model is not available. \
Ensure the ONNX model is downloaded. Tables cannot be extracted without it."
)));
}
available
} else {
false
};
if has_table_model {
if let (Some(images @ [_, ..]), Some(results @ [_, ..])) = (layout_images, layout_results) {
#[cfg(not(target_arch = "wasm32"))]
let recognized_tables: Vec<Vec<crate::types::Table>> = table_pages
.iter()
.map(|tp| {
if let Some(variant) = slanet_variant {
let Some(mut slanet) =
crate::layout::take_or_create_slanet(variant, acceleration, session_thread_budget)
else {
tracing::warn!("SLANeXT model unavailable in worker thread");
return Vec::new();
};
if let (Some(page_image), Some(page_result)) =
(images.get(tp.page_idx), results.get(tp.page_idx))
{
let hints = &hints_pages[tp.page_idx];
let mut classifier_pair = if is_auto {
match (
crate::layout::take_or_create_table_classifier(
acceleration,
session_thread_budget,
),
crate::layout::take_or_create_slanet(
"slanet_wireless",
acceleration,
session_thread_budget,
),
) {
(Some(classifier), Some(alternate)) => Some((classifier, alternate)),
_ => None,
}
} else {
None
};
let classifier_arg = classifier_pair
.as_mut()
.map(|(classifier, alternate)| (&mut ***classifier, &mut ***alternate));
let slanet_tables = super::regions::recognize_tables_slanet(
page_image,
hints,
&tp.words,
page_result,
tp.page_height,
tp.page_idx,
&mut slanet,
classifier_arg,
);
if !slanet_tables.is_empty() {
return slanet_tables;
}
}
let hints = &hints_pages[tp.page_idx];
super::regions::extract_tables_from_layout_hints(
&tp.words,
hints,
tp.page_idx,
tp.page_height,
0.5,
allow_single_column,
false,
)
} else {
let Some(mut tatr) =
crate::layout::take_or_create_tatr(acceleration, session_thread_budget)
else {
tracing::warn!("TATR model unavailable in worker thread");
return Vec::new();
};
if let (Some(page_image), Some(page_result)) =
(images.get(tp.page_idx), results.get(tp.page_idx))
{
let hints = &hints_pages[tp.page_idx];
let tatr_tables = super::regions::recognize_tables_for_native_page(
page_image,
hints,
&tp.words,
page_result,
tp.page_height,
super::regions::NativeTatrRecognitionOptions {
page_index: tp.page_idx,
allow_single_column,
},
&mut tatr,
);
if !tatr_tables.is_empty() {
return tatr_tables;
}
}
let hints = &hints_pages[tp.page_idx];
super::regions::extract_tables_from_layout_hints(
&tp.words,
hints,
tp.page_idx,
tp.page_height,
0.5,
allow_single_column,
false,
)
}
})
.collect();
#[cfg(target_arch = "wasm32")]
let recognized_tables: Vec<Vec<crate::types::Table>> = table_pages
.iter()
.map(|tp| {
if let (Some(page_image), Some(page_result)) =
(images.get(tp.page_idx), results.get(tp.page_idx))
{
let hints = &hints_pages[tp.page_idx];
let Some(mut tatr) =
crate::layout::take_or_create_tatr(acceleration, session_thread_budget)
else {
return Vec::new();
};
let tatr_tables = super::regions::recognize_tables_for_native_page(
page_image,
hints,
&tp.words,
page_result,
tp.page_height,
super::regions::NativeTatrRecognitionOptions {
page_index: tp.page_idx,
allow_single_column,
},
&mut tatr,
);
if !tatr_tables.is_empty() {
return tatr_tables;
}
super::regions::extract_tables_from_layout_hints(
&tp.words,
hints,
tp.page_idx,
tp.page_height,
0.5,
allow_single_column,
false,
)
} else {
Vec::new()
}
})
.collect();
layout_tables.extend(recognized_tables.into_iter().flatten());
} else {
for tp in &table_pages {
if cancel_token.is_some_and(|t| t.is_cancelled()) {
tracing::debug!("native structure pipeline: cancelled during heuristic table extraction");
break;
}
let hints = &hints_pages[tp.page_idx];
layout_tables.extend(super::regions::extract_tables_from_layout_hints(
&tp.words,
hints,
tp.page_idx,
tp.page_height,
0.5,
allow_single_column,
false,
));
}
}
} else {
for tp in &table_pages {
if cancel_token.is_some_and(|t| t.is_cancelled()) {
tracing::debug!("native structure pipeline: cancelled during heuristic table extraction");
break;
}
let hints = &hints_pages[tp.page_idx];
layout_tables.extend(super::regions::extract_tables_from_layout_hints(
&tp.words,
hints,
tp.page_idx,
tp.page_height,
0.5,
allow_single_column,
false,
));
}
}
}
#[cfg(not(feature = "layout-detection"))]
for tp in &table_pages {
if cancel_token.is_some_and(|t| t.is_cancelled()) {
tracing::debug!("native structure pipeline: cancelled during heuristic table extraction");
break;
}
let hints = &hints_pages[tp.page_idx];
layout_tables.extend(super::regions::extract_tables_from_layout_hints(
&tp.words,
hints,
tp.page_idx,
tp.page_height,
0.5,
allow_single_column,
false,
));
}
for (page_idx, words, page_height, synthetic_hints) in &geometric_table_pages {
if cancel_token.is_some_and(|t| t.is_cancelled()) {
tracing::debug!("native structure pipeline: cancelled during geometric table fallback");
break;
}
let before = layout_tables.len();
layout_tables.extend(super::regions::extract_tables_from_layout_hints(
words,
synthetic_hints,
*page_idx,
*page_height,
0.5,
allow_single_column,
true,
));
let recovered = layout_tables.len() - before;
if recovered > 0 {
tracing::debug!(
page = page_idx,
recovered,
"geometric table fallback: recovered table(s) the ML detector missed"
);
}
}
}
tracing::debug!(
layout_tables_found = layout_tables.len(),
"native layout table extraction complete"
);
#[cfg(feature = "layout-detection")]
let overlap_preference = table_overlap_preference;
#[cfg(not(feature = "layout-detection"))]
let overlap_preference = crate::core::config::layout::TableOverlapPreference::Content;
let stitched_native_tables = stitch_fragmented_tables(tables.to_vec(), &all_page_segments);
let emitted_tables = prepare_emitted_tables(&stitched_native_tables, layout_tables, overlap_preference);
let extracted_table_bboxes_by_page = table_bboxes_by_page(&emitted_tables);
tracing::debug!(
native_tables = tables.len(),
emitted_tables = emitted_tables.len(),
pages_with_bboxes = extracted_table_bboxes_by_page.len(),
"native table bbox suppression map built"
);
#[cfg(feature = "layout-detection")]
let validations_by_page: ahash::AHashMap<usize, Vec<super::regions::layout_validation::RegionValidation>> = {
let mut map = ahash::AHashMap::new();
if let (Some(images), Some(results), Some(hints_pages)) = (layout_images, layout_results, layout_hints) {
for page_idx in 0..page_count {
if let (Some(img), Some(res), Some(hints)) =
(images.get(page_idx), results.get(page_idx), hints_pages.get(page_idx))
{
let validations = super::regions::layout_validation::validate_page_regions(img, hints, res);
if validations.contains(&super::regions::layout_validation::RegionValidation::Empty) {
tracing::debug!(
page = page_idx,
empty_count = validations
.iter()
.filter(|v| **v == super::regions::layout_validation::RegionValidation::Empty)
.count(),
"native layout validation: found empty regions"
);
}
map.insert(page_idx, validations);
}
}
}
map
};
#[cfg(feature = "layout-detection")]
let effective_layout_hints = layout_hints;
#[cfg(feature = "layout-detection")]
let native_layout_projections: Vec<Option<NativeLayoutProjection>> = (0..page_count)
.map(|page_index| {
let hints = effective_layout_hints.and_then(|pages| pages.get(page_index))?;
let validations = validations_by_page
.get(&page_index)
.map(Vec::as_slice)
.unwrap_or_default();
let wrapper_ownership = wrapper_ownership_by_hint(hints, validations);
if !crate::extractors::pdf::reading_order::has_eligible_layout_hints(hints, &wrapper_ownership) {
return None;
}
let table_bboxes = extracted_table_bboxes_by_page
.get(&page_index)
.map(Vec::as_slice)
.unwrap_or_default();
let projected_segments =
filter_segments_by_table_bboxes(all_page_segments[page_index].clone(), table_bboxes);
let no_reorder = super::layout_debug::layout_debug_flags().no_reorder;
let page_width_pts = layout_results
.and_then(|results| results.get(page_index))
.map(|result| result.page_width_pts);
let groups = crate::extractors::pdf::reading_order::plan_segment_groups_by_layout(
&projected_segments,
hints,
&wrapper_ownership,
no_reorder,
page_width_pts,
);
let group_bounds = layout_group_bounds(&groups, &projected_segments);
Some(NativeLayoutProjection {
groups,
group_bounds,
classification_hints: regular_layout_hints(hints),
})
})
.collect();
let witnesses = TextRepairWitnesses {
hyphens: collect_hyphen_witnesses(&all_page_segments),
words: collect_word_witnesses(&all_page_segments),
};
let page_inputs: Vec<PageInput> = (0..page_count)
.map(|i| {
let heuristic_segments = std::mem::take(&mut all_page_segments[i]);
let paragraph_gap_ys = compute_paragraph_gap_ys(&heuristic_segments);
PageInput {
page_index: i,
struct_paragraphs: None,
heuristic_segments,
page_hints: None,
table_bboxes: extracted_table_bboxes_by_page.get(&i).cloned().unwrap_or_default(),
preserve_native_semantics: true,
use_layout_reading_order: false,
#[cfg(feature = "layout-detection")]
hint_validations: validations_by_page.get(&i).cloned().unwrap_or_default(),
#[cfg(feature = "layout-detection")]
page_width_pts: layout_results
.and_then(|results| results.get(i))
.map(|result| result.page_width_pts),
needs_classify: false,
paragraph_gap_ys,
include_headers,
include_footers,
include_footnotes,
}
})
.collect();
if cancel_token.is_some_and(|t| t.is_cancelled()) {
return Err(crate::pdf::error::PdfError::TextExtractionFailed(
"extraction cancelled".to_string(),
));
}
#[cfg(not(target_arch = "wasm32"))]
let mut all_page_paragraphs: Vec<Vec<PdfParagraph>> = page_inputs
.into_par_iter()
.map(|input| process_single_page(input, &heading_map, doc_body_font_size, &witnesses))
.collect();
#[cfg(target_arch = "wasm32")]
let mut all_page_paragraphs: Vec<Vec<PdfParagraph>> = page_inputs
.into_iter()
.map(|input| process_single_page(input, &heading_map, doc_body_font_size, &witnesses))
.collect();
refine_heading_hierarchy(&mut all_page_paragraphs);
demote_unnumbered_subsections(&mut all_page_paragraphs);
demote_heading_runs(&mut all_page_paragraphs);
split_colon_semicolon_run_in_lists(&mut all_page_paragraphs);
if strip_repeating_text {
mark_cross_page_repeating_text(&mut all_page_paragraphs, &page_heights);
mark_cross_page_repeating_short_text(&mut all_page_paragraphs);
}
if !include_watermarks {
mark_arxiv_noise(&mut all_page_paragraphs);
}
recover_headings_from_outline(&mut all_page_paragraphs, outline_entries);
mark_validated_page_numbers(&mut all_page_paragraphs, &page_heights);
for page in &mut all_page_paragraphs {
retain_page_furniture_safely(page);
}
if strip_repeating_text {
deduplicate_paragraphs(&mut all_page_paragraphs, &extracted_table_bboxes_by_page);
}
compact_final_heading_hierarchy(&mut all_page_paragraphs);
promote_repeated_body_size_bold_headings(&mut all_page_paragraphs, doc_body_font_size);
#[cfg(feature = "layout-detection")]
for (page, projection) in all_page_paragraphs.iter_mut().zip(native_layout_projections) {
let Some(projection) = projection else {
continue;
};
assign_native_paragraph_layout(page, &projection.groups, &projection.group_bounds);
super::layout_classify::annotate_layout_classes(page, &projection.classification_hints, 0.5, 0.2);
}
suppress_table_dominant_paragraph_spill(&mut all_page_paragraphs, &emitted_tables);
reorder_pages_by_layout_region(&mut all_page_paragraphs);
let total_paragraphs: usize = all_page_paragraphs.iter().map(|p| p.len()).sum();
tracing::debug!(
total_paragraphs,
heading_map_len = heading_map.len(),
"native structure pipeline: paragraph extraction complete, assembling document"
);
let effective_image_positions = if inject_placeholders { image_positions } else { &[] };
let mut doc = assemble_internal_document(
all_page_paragraphs,
&emitted_tables,
images,
effective_image_positions,
&witnesses.hyphens,
);
for elem in &mut doc.elements {
if elem.text.is_empty() {
continue;
}
let t1 = repair_contextual_ligatures(&elem.text);
let t2 = expand_ligatures_with_space_absorption(&t1);
let t3 = normalize_unicode_text(&t2);
if let Cow::Owned(normalized) = t3 {
elem.text = normalized;
} else if let Cow::Owned(normalized) = t2 {
elem.text = normalized;
} else if let Cow::Owned(normalized) = t1 {
elem.text = normalized;
}
}
tracing::debug!(
elements = doc.elements.len(),
"native structure pipeline: assembly complete"
);
Ok(doc)
}
const TABLE_STITCH_Y_GAP_TOLERANCE_PTS: f64 = 4.0;
const TABLE_STITCH_X_TOLERANCE_PTS: f64 = 6.0;
const TABLE_STITCH_MAX_CHAIN_FRAGMENTS: usize = 12;
const TABLE_STITCH_TRAILING_RECOVERY_MAX_ROWS: usize = 6;
const TABLE_STITCH_TRAILING_ROW_GAP_MULTIPLIER: f32 = 1.8;
fn stitch_fragmented_tables(
tables: Vec<crate::types::Table>,
all_page_segments: &[Vec<SegmentData>],
) -> Vec<crate::types::Table> {
let mut by_page: ahash::AHashMap<u32, Vec<crate::types::Table>> = ahash::AHashMap::new();
let mut unbboxed = Vec::new();
for table in tables {
if table.bounding_box.is_some() {
by_page.entry(table.page_number).or_default().push(table);
} else {
unbboxed.push(table);
}
}
let mut result = unbboxed;
let mut page_numbers: Vec<u32> = by_page.keys().copied().collect();
page_numbers.sort_unstable();
for page_number in page_numbers {
if let Some(page_tables) = by_page.remove(&page_number) {
result.extend(stitch_page_tables(page_tables, all_page_segments));
}
}
result
}
fn assign_deterministic_table_ids(tables: &mut [crate::types::Table]) {
for (index, table) in tables.iter_mut().enumerate() {
table.table_id = Some(format!("table-{}", index + 1));
if table.columns.is_none() {
table.columns = table.cells.first().cloned();
}
}
}
fn stitch_page_tables(
mut fragments: Vec<crate::types::Table>,
all_page_segments: &[Vec<SegmentData>],
) -> Vec<crate::types::Table> {
fragments.sort_by(|a, b| {
let a_top = a.bounding_box.map_or(f64::MIN, |bbox| bbox.y1);
let b_top = b.bounding_box.map_or(f64::MIN, |bbox| bbox.y1);
b_top.total_cmp(&a_top)
});
let mut output = Vec::with_capacity(fragments.len());
let mut index = 0;
while index < fragments.len() {
let mut chain_end = index + 1;
while chain_end < fragments.len()
&& chain_end - index < TABLE_STITCH_MAX_CHAIN_FRAGMENTS
&& fragments_are_stitchable(&fragments[chain_end - 1], &fragments[chain_end])
{
chain_end += 1;
}
if chain_end - index >= 2 {
let chain = fragments[index..chain_end].to_vec();
output.push(merge_table_chain(chain, all_page_segments));
} else {
output.push(fragments[index].clone());
}
index = chain_end;
}
output
}
fn fragments_are_stitchable(prev: &crate::types::Table, next: &crate::types::Table) -> bool {
if prev.page_number != next.page_number {
return false;
}
let (Some(a), Some(b)) = (prev.bounding_box, next.bounding_box) else {
return false;
};
let prev_cols = prev.cells.first().map_or(0, Vec::len);
let next_cols = next.cells.first().map_or(0, Vec::len);
if prev_cols == 0 || prev_cols != next_cols {
return false;
}
(a.y0 - b.y1).abs() <= TABLE_STITCH_Y_GAP_TOLERANCE_PTS
&& (a.x0 - b.x0).abs() <= TABLE_STITCH_X_TOLERANCE_PTS
&& (a.x1 - b.x1).abs() <= TABLE_STITCH_X_TOLERANCE_PTS
}
fn merge_table_chain(chain: Vec<crate::types::Table>, all_page_segments: &[Vec<SegmentData>]) -> crate::types::Table {
let column_count = chain
.iter()
.filter_map(|table| table.cells.first())
.map(Vec::len)
.max()
.unwrap_or(0);
let page_number = chain[0].page_number;
let mut bbox = chain
.iter()
.find_map(|table| table.bounding_box)
.unwrap_or(crate::types::BoundingBox {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
for table in &chain {
if let Some(b) = table.bounding_box {
bbox.x0 = bbox.x0.min(b.x0);
bbox.x1 = bbox.x1.max(b.x1);
bbox.y0 = bbox.y0.min(b.y0);
bbox.y1 = bbox.y1.max(b.y1);
}
}
let mut rows: Vec<Vec<String>> = chain
.iter()
.map(|table| crate::pdf::table_reconstruct::merge_rows_columnwise(&table.cells, column_count))
.collect();
if let Some(page_segments) = all_page_segments.get((page_number.saturating_sub(1)) as usize) {
recover_trailing_continuation_rows(&mut rows, &mut bbox, column_count, page_segments);
}
let markdown = crate::pdf::table_reconstruct::table_to_markdown(&rows);
let columns = rows.first().cloned();
crate::types::Table {
cells: rows,
markdown,
page_number,
bounding_box: Some(bbox),
columns,
..Default::default()
}
}
fn recover_trailing_continuation_rows(
rows: &mut Vec<Vec<String>>,
bbox: &mut crate::types::BoundingBox,
column_count: usize,
page_segments: &[SegmentData],
) {
if column_count == 0 || page_segments.is_empty() {
return;
}
let page_height = page_segments
.iter()
.map(|s| s.y + s.height)
.fold(0.0_f32, f32::max)
.max(792.0);
let x_lo = (bbox.x0 - TABLE_STITCH_X_TOLERANCE_PTS) as f32;
let x_hi = (bbox.x1 + TABLE_STITCH_X_TOLERANCE_PTS) as f32;
let mut search_floor = bbox.y0 as f32;
for _ in 0..TABLE_STITCH_TRAILING_RECOVERY_MAX_ROWS {
let band_words: Vec<crate::pdf::table_reconstruct::HocrWord> = page_segments
.iter()
.filter(|seg| {
!seg.text.trim().is_empty()
&& seg.y + seg.height <= search_floor + TABLE_STITCH_Y_GAP_TOLERANCE_PTS as f32
&& seg.x + seg.width >= x_lo
&& seg.x <= x_hi
})
.flat_map(|seg| crate::pdf::table_reconstruct::split_segment_to_words(seg, page_height))
.collect();
if band_words.is_empty() {
break;
}
let Some((entity_words, entity_bottom_image_y)) = take_next_entity_band(&band_words) else {
break;
};
let col_gap = super::regions::tables::compute_adaptive_column_gap(&entity_words, (x_hi - x_lo).max(1.0));
let grid = crate::pdf::table_reconstruct::reconstruct_table(&entity_words, col_gap, 0.5);
if grid.is_empty() || grid[0].len() != column_count {
break;
}
let merged_row = crate::pdf::table_reconstruct::merge_rows_columnwise(&grid, column_count);
if merged_row.iter().all(|cell| cell.trim().is_empty()) {
break;
}
let entity_bottom_pdf_y = page_height - entity_bottom_image_y as f32;
rows.push(merged_row);
bbox.y0 = bbox.y0.min(entity_bottom_pdf_y as f64);
search_floor = entity_bottom_pdf_y;
}
}
fn take_next_entity_band(
words: &[crate::pdf::table_reconstruct::HocrWord],
) -> Option<(Vec<crate::pdf::table_reconstruct::HocrWord>, u32)> {
if words.is_empty() {
return None;
}
let mut heights: Vec<u32> = words.iter().map(|w| w.height).collect();
heights.sort_unstable();
let median_height = heights[heights.len() / 2].max(1);
let row_gap_split = (median_height as f32 * TABLE_STITCH_TRAILING_ROW_GAP_MULTIPLIER) as u32;
let row_tolerance = (median_height / 2).max(3);
let mut sorted: Vec<&crate::pdf::table_reconstruct::HocrWord> = words.iter().collect();
sorted.sort_by_key(|w| w.top);
let mut band: Vec<crate::pdf::table_reconstruct::HocrWord> = Vec::new();
let mut band_bottom = 0u32;
let mut last_row_yc: Option<u32> = None;
let mut idx = 0;
while idx < sorted.len() {
let row_yc = sorted[idx].top + sorted[idx].height / 2;
let mut end = idx + 1;
while end < sorted.len() {
let yc = sorted[end].top + sorted[end].height / 2;
if yc.abs_diff(row_yc) <= row_tolerance {
end += 1;
} else {
break;
}
}
if let Some(prev_yc) = last_row_yc
&& row_yc > prev_yc
&& row_yc - prev_yc > row_gap_split
&& !band.is_empty()
{
break;
}
for word in &sorted[idx..end] {
band_bottom = band_bottom.max(word.top + word.height);
band.push((*word).clone());
}
last_row_yc = Some(row_yc);
idx = end;
}
if band.is_empty() {
None
} else {
Some((band, band_bottom))
}
}
fn prepare_emitted_tables(
native_tables: &[crate::types::Table],
layout_tables: Vec<crate::types::Table>,
overlap_preference: crate::core::config::layout::TableOverlapPreference,
) -> Vec<crate::types::Table> {
let mut emitted_tables: Vec<crate::types::Table> = native_tables.iter().cloned().chain(layout_tables).collect();
emitted_tables.retain(|table| !table.markdown.trim().is_empty());
let native_count = native_tables
.iter()
.filter(|table| !table.markdown.trim().is_empty())
.count();
deduplicate_overlapping_tables(&mut emitted_tables, native_count, overlap_preference);
normalize_sparse_currency_affix_columns(&mut emitted_tables);
normalize_wrapped_financial_rows(&mut emitted_tables);
deduplicate_identical_tables(&mut emitted_tables);
assign_deterministic_table_ids(&mut emitted_tables);
emitted_tables
}
const MAX_CURRENCY_AFFIX_CELLS: usize = 3;
const MAX_CURRENCY_AFFIX_OCCUPANCY: f64 = 0.1;
const MIN_FINANCIAL_TARGET_CELLS: usize = 5;
const MIN_FINANCIAL_TARGET_RATIO: f64 = 0.9;
const MIN_WRAPPED_FINANCIAL_VALUE_ROWS: usize = 8;
const FINANCIAL_COLUMN_HEADERS: &[&str] = &[
"shares",
"par",
"principal",
"quantity",
"value",
"market value",
"cost",
];
const ISO_CURRENCY_CODES: &[&str] = &[
"AUD", "BRL", "CAD", "CHF", "CNY", "DKK", "EUR", "GBP", "HKD", "INR", "JPY", "KRW", "MXN", "NOK", "NZD", "SEK",
"SGD", "USD", "ZAR",
];
fn normalize_sparse_currency_affix_columns(tables: &mut [crate::types::Table]) {
for table in tables {
normalize_sparse_currency_affix_columns_in_table(table);
}
}
fn normalize_wrapped_financial_rows(tables: &mut [crate::types::Table]) {
for table in tables {
if !is_wrapped_financial_table(&table.cells) {
continue;
}
fold_wrapped_financial_rows(&mut table.cells);
table.markdown = crate::extractors::frontmatter_utils::cells_to_markdown(&table.cells);
table.columns = table.cells.first().cloned();
}
}
fn is_wrapped_financial_table(rows: &[Vec<String>]) -> bool {
let Some(header) = rows.first() else {
return false;
};
if header.len() < 3
|| rows.len() <= 1
|| !header.iter().skip(1).all(|cell| is_financial_column_header(cell))
|| rows.iter().any(|row| row.len() != header.len())
{
return false;
}
let body = &rows[1..];
let value_rows = body.iter().filter(|row| is_value_bearing_financial_row(row)).count();
let descriptor_rows = body
.iter()
.filter(|row| is_descriptor_only_financial_row(row) && !is_financial_section_label(row))
.count();
value_rows >= MIN_WRAPPED_FINANCIAL_VALUE_ROWS
&& descriptor_rows > value_rows
&& body
.iter()
.all(|row| is_descriptor_only_financial_row(row) || is_value_bearing_financial_row(row))
}
fn is_descriptor_only_financial_row(row: &[String]) -> bool {
row.first().is_some_and(|cell| !cell.trim().is_empty()) && row.iter().skip(1).all(|cell| cell.trim().is_empty())
}
fn is_value_bearing_financial_row(row: &[String]) -> bool {
row.first().is_some_and(|cell| !cell.trim().is_empty()) && row.iter().skip(1).all(|cell| is_financial_value(cell))
}
fn is_financial_section_label(row: &[String]) -> bool {
if !is_descriptor_only_financial_row(row) {
return false;
}
let text = row[0].trim().to_ascii_lowercase();
text.contains("(continued)") || has_allocation_percentage_suffix(&text)
}
fn has_allocation_percentage_suffix(text: &str) -> bool {
const MAX_FOOTNOTE_MARKER_CHARS: usize = 4;
let Some((dash_index, dash)) = text
.char_indices()
.rev()
.find(|(_, character)| matches!(character, '–' | '—'))
else {
return false;
};
if text[..dash_index].trim().is_empty() {
return false;
}
let suffix = text[dash_index + dash.len_utf8()..].trim();
let Some((allocation, remainder)) = suffix.split_once('%') else {
return false;
};
let Ok(allocation) = allocation.trim().parse::<f64>() else {
return false;
};
if !allocation.is_finite() || !(0.0..=100.0).contains(&allocation) {
return false;
}
let remainder = remainder.trim();
if remainder.is_empty() {
return true;
}
let Some(marker) = remainder.strip_prefix('(').and_then(|value| value.strip_suffix(')')) else {
return false;
};
!marker.is_empty()
&& marker.chars().count() <= MAX_FOOTNOTE_MARKER_CHARS
&& marker.chars().all(char::is_alphanumeric)
}
fn is_financial_value(cell: &str) -> bool {
let trimmed = cell.trim();
if is_financial_number(trimmed) {
return true;
}
trimmed
.split_once(' ')
.is_some_and(|(marker, value)| is_currency_marker(marker) && is_financial_number(value.trim()))
}
fn fold_wrapped_financial_rows(rows: &mut Vec<Vec<String>>) {
let mut folded = Vec::with_capacity(rows.len());
folded.extend(rows.first().cloned());
let mut pending = Vec::new();
for mut row in rows.iter().skip(1).cloned() {
if is_financial_section_label(&row) {
folded.append(&mut pending);
folded.push(row);
continue;
}
if is_descriptor_only_financial_row(&row) {
pending.push(row);
continue;
}
if !pending.is_empty() {
let prefix = pending
.drain(..)
.filter_map(|pending_row| pending_row.into_iter().next())
.collect::<Vec<_>>()
.join(" ");
row[0] = format!("{prefix} {}", row[0]);
}
folded.push(row);
}
folded.extend(pending);
*rows = folded;
}
fn normalize_sparse_currency_affix_columns_in_table(table: &mut crate::types::Table) {
let Some(header) = table.cells.first() else {
return;
};
if table.cells.len() <= 1 || header.len() < 2 {
return;
}
let mut source_columns = (0..header.len() - 1)
.filter(|&source| {
header[source].trim().is_empty()
&& header
.get(source + 1)
.is_some_and(|target| is_financial_column_header(target))
&& is_sparse_currency_affix_column(&table.cells[1..], source, source + 1)
})
.collect::<Vec<_>>();
source_columns.sort_unstable_by(|left, right| right.cmp(left));
let normalized = !source_columns.is_empty();
for source in source_columns {
merge_currency_affix_column(&mut table.cells, source);
}
if normalized {
table.markdown = crate::extractors::frontmatter_utils::cells_to_markdown(&table.cells);
table.columns = table.cells.first().cloned();
}
}
fn is_financial_column_header(header: &str) -> bool {
let normalized = header.trim().to_ascii_lowercase();
FINANCIAL_COLUMN_HEADERS
.iter()
.any(|candidate| normalized == *candidate || normalized.starts_with(&format!("{candidate} ")))
}
fn is_sparse_currency_affix_column(rows: &[Vec<String>], source: usize, target: usize) -> bool {
let source_cells = rows
.iter()
.filter_map(|row| row.get(source))
.map(|cell| cell.trim())
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>();
let occupied_limit =
MAX_CURRENCY_AFFIX_CELLS.min(((rows.len() as f64) * MAX_CURRENCY_AFFIX_OCCUPANCY).ceil() as usize);
if source_cells.is_empty()
|| source_cells.len() > occupied_limit
|| !source_cells.iter().all(|cell| is_currency_marker(cell))
|| rows.iter().any(|row| {
row.get(source).is_some_and(|cell| !cell.trim().is_empty())
&& row.get(target).is_none_or(|cell| cell.trim().is_empty())
})
{
return false;
}
let target_cells = rows
.iter()
.filter_map(|row| row.get(target))
.map(|cell| cell.trim())
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>();
target_cells.len() >= MIN_FINANCIAL_TARGET_CELLS
&& target_cells.iter().filter(|cell| is_financial_number(cell)).count() as f64 / target_cells.len() as f64
>= MIN_FINANCIAL_TARGET_RATIO
}
fn is_currency_marker(cell: &str) -> bool {
const CURRENCY_SYMBOLS: &[&str] = &["$", "€", "£", "¥", "₹", "₩", "₽", "₺", "₪", "₫", "₦", "₱", "฿"];
CURRENCY_SYMBOLS.contains(&cell) || ISO_CURRENCY_CODES.contains(&cell.to_ascii_uppercase().as_str())
}
fn is_financial_number(cell: &str) -> bool {
let mut has_digit = false;
cell.chars().all(|character| {
if character.is_ascii_digit() {
has_digit = true;
true
} else {
character.is_ascii_whitespace() || matches!(character, ',' | '.' | '-' | '+' | '(' | ')' | '%')
}
}) && has_digit
}
fn merge_currency_affix_column(rows: &mut [Vec<String>], source: usize) {
for row in rows {
if row.len() <= source {
continue;
}
if let Some(marker) = row.get(source).map(|cell| cell.trim()).filter(|cell| !cell.is_empty())
&& let Some(value) = row
.get(source + 1)
.map(|cell| cell.trim())
.filter(|cell| !cell.is_empty())
{
row[source + 1] = format!("{marker} {value}");
}
row.remove(source);
}
}
fn deduplicate_identical_tables(tables: &mut Vec<crate::types::Table>) {
if tables.len() < 2 {
return;
}
let mut seen: ahash::AHashSet<(u32, &str)> = ahash::AHashSet::with_capacity(tables.len());
let mut keep = vec![true; tables.len()];
for (index, table) in tables.iter().enumerate() {
if !seen.insert((table.page_number, table.markdown.as_str())) {
keep[index] = false;
}
}
let mut index = 0;
tables.retain(|_| {
let keep_this = keep[index];
index += 1;
keep_this
});
}
#[derive(Clone)]
struct TableCoverage {
bbox: crate::types::BoundingBox,
cell_text: String,
}
fn normalize_for_table_coverage(text: &str) -> String {
text.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn table_bboxes_by_page(tables: &[crate::types::Table]) -> ahash::AHashMap<usize, Vec<TableCoverage>> {
let mut coverage_by_page: ahash::AHashMap<usize, Vec<TableCoverage>> = ahash::AHashMap::new();
for table in tables {
if let Some(bbox) = table.bounding_box {
let cell_text = if table.cells.iter().any(|row| !row.is_empty()) {
table
.cells
.iter()
.flat_map(|row| row.iter())
.map(|cell| normalize_for_table_coverage(cell))
.collect::<String>()
} else {
normalize_for_table_coverage(&table.markdown)
};
coverage_by_page
.entry(table.page_number.saturating_sub(1) as usize)
.or_default()
.push(TableCoverage { bbox, cell_text });
}
}
coverage_by_page
}
fn filter_segments_by_table_bboxes(segments: Vec<SegmentData>, tables: &[TableCoverage]) -> Vec<SegmentData> {
if tables.is_empty() {
return segments;
}
segments
.into_iter()
.filter(|seg| {
let seg_area = seg.width * seg.height;
let seg_text = seg.text.trim();
if seg_area <= 0.0 || seg_text.is_empty() {
return true;
}
let normalized = normalize_for_table_coverage(seg_text);
!tables.iter().any(|table| {
let bb = &table.bbox;
let inter_left = seg.x.max(bb.x0 as f32);
let inter_right = (seg.x + seg.width).min(bb.x1 as f32);
let inter_bottom = seg.y.max(bb.y0 as f32);
let inter_top = (seg.y + seg.height).min(bb.y1 as f32);
if inter_left >= inter_right || inter_bottom >= inter_top {
return false;
}
let inter_area = (inter_right - inter_left) * (inter_top - inter_bottom);
inter_area / seg_area >= 0.5 && table.cell_text.contains(&normalized)
})
})
.collect()
}
fn fused_text_repairs<'a>(text: &'a str, word_witnesses: &WordWitnesses) -> Cow<'a, str> {
let t1 = normalize_text_encoding(text);
let t2 = repair_ligature_spaces(&t1, word_witnesses);
let t3 = expand_ligatures_with_space_absorption(&t2);
let t3b = collapse_spaced_hyphens(&t3);
let t4 = normalize_unicode_text(&t3b);
let t5 = clean_duplicate_punctuation(&t4);
match (&t1, &t2, &t3, &t3b, &t4, &t5) {
(
Cow::Borrowed(_),
Cow::Borrowed(_),
Cow::Borrowed(_),
Cow::Borrowed(_),
Cow::Borrowed(_),
Cow::Borrowed(_),
) => Cow::Borrowed(text),
_ => Cow::Owned(t5.into_owned()),
}
}
fn deduplicate_overlapping_tables(
tables: &mut Vec<crate::types::Table>,
native_count: usize,
preference: crate::core::config::layout::TableOverlapPreference,
) {
use crate::core::config::layout::TableOverlapPreference;
if tables.len() < 2 {
return;
}
let mut to_remove = ahash::AHashSet::new();
let mut protected_layout_children = ahash::AHashSet::new();
if preference != TableOverlapPreference::Native {
for (parents, children) in side_by_side_layout_replacements(tables, native_count) {
protected_layout_children.extend(children);
to_remove.extend(parents);
}
}
for i in 0..tables.len() {
if to_remove.contains(&i) {
continue;
}
for j in (i + 1)..tables.len() {
if to_remove.contains(&j) {
continue;
}
if tables[i].page_number != tables[j].page_number {
continue;
}
if let (Some(a), Some(b)) = (&tables[i].bounding_box, &tables[j].bounding_box) {
let inter_x = (a.x1.min(b.x1) - a.x0.max(b.x0)).max(0.0);
let inter_y = (a.y1.min(b.y1) - a.y0.max(b.y0)).max(0.0);
let intersection = inter_x * inter_y;
let area_a = (a.x1 - a.x0) * (a.y1 - a.y0);
let area_b = (b.x1 - b.x0) * (b.y1 - b.y0);
let min_area = area_a.min(area_b);
if min_area > 0.0 && intersection / min_area > 0.5 {
let i_is_native = i < native_count;
let j_is_native = j < native_count;
let mixed_origin = i_is_native != j_is_native;
let i_is_protected = protected_layout_children.contains(&i);
let j_is_protected = protected_layout_children.contains(&j);
let remove = match (i_is_protected, j_is_protected) {
(true, false) => Some(j),
(false, true) => Some(i),
(true, true) => {
let duplicate = intersection / area_a >= LAYOUT_CHILD_DUPLICATE_OVERLAP
&& intersection / area_b >= LAYOUT_CHILD_DUPLICATE_OVERLAP;
duplicate.then(|| lower_content_table(tables, i, j))
}
(false, false) => Some(match preference {
TableOverlapPreference::Native if mixed_origin => {
if i_is_native {
j
} else {
i
}
}
TableOverlapPreference::Layout if mixed_origin => {
if i_is_native {
i
} else {
j
}
}
_ => lower_content_table(tables, i, j),
}),
};
let Some(remove) = remove else {
continue;
};
to_remove.insert(remove);
if remove == i {
break;
}
}
}
}
}
let surviving_protected: Vec<_> = protected_layout_children
.iter()
.copied()
.filter(|index| !to_remove.contains(index))
.collect();
let affected_rows: Vec<_> = surviving_protected
.iter()
.filter_map(|&index| tables[index].bounding_box.map(|bbox| (tables[index].page_number, bbox)))
.collect();
let mut idx = 0;
tables.retain(|_| {
let keep = !to_remove.contains(&idx);
idx += 1;
keep
});
canonicalize_affected_table_rows(tables, affected_rows);
}
const SIDE_BY_SIDE_CHILD_PARENT_OVERLAP: f64 = 0.8;
const SIDE_BY_SIDE_CORRESPONDENCE_EPSILON: f64 = 0.005;
const SIDE_BY_SIDE_VERTICAL_OVERLAP: f64 = 0.6;
const SIDE_BY_SIDE_PARENT_WIDTH_COVERAGE: f64 = 0.75;
const SIDE_BY_SIDE_PARENT_HEIGHT_COVERAGE: f64 = 0.75;
const SIDE_BY_SIDE_PARENT_AREA_COVERAGE: f64 = 0.65;
const LAYOUT_CHILD_DUPLICATE_OVERLAP: f64 = 0.9;
fn side_by_side_layout_replacements(
tables: &[crate::types::Table],
native_count: usize,
) -> Vec<(Vec<usize>, Vec<usize>)> {
let native_count = native_count.min(tables.len());
let rows = native_candidate_rows(tables, native_count);
let mut used_native = ahash::AHashSet::new();
let mut used_layout = ahash::AHashSet::new();
let mut replacements = Vec::new();
for parent in rows.iter().flatten().copied() {
let parent_bbox = tables[parent].bounding_box.as_ref().expect("candidate has bbox");
let mut children = layout_children_for_parent(tables, native_count, tables[parent].page_number, parent_bbox);
children.retain(|child| !used_layout.contains(child));
if !used_native.contains(&parent) && is_side_by_side_replacement(tables, parent_bbox, &children) {
used_native.insert(parent);
used_layout.extend(children.iter().copied());
replacements.push((vec![parent], children));
}
}
replacements.extend(side_by_side_native_cohort_replacements(
tables,
native_count,
&rows,
&mut used_native,
&mut used_layout,
));
replacements
}
fn side_by_side_native_cohort_replacements(
tables: &[crate::types::Table],
native_count: usize,
rows: &[Vec<usize>],
used_native: &mut ahash::AHashSet<usize>,
used_layout: &mut ahash::AHashSet<usize>,
) -> Vec<(Vec<usize>, Vec<usize>)> {
let mut replacements = Vec::new();
for pair in rows.iter().flat_map(|row| row.windows(2)) {
let parents = vec![pair[0], pair[1]];
if parents.iter().any(|parent| used_native.contains(parent)) {
continue;
}
let Some(parent_bbox) = table_union_bbox(&tables[parents[0]], &tables[parents[1]]) else {
continue;
};
if !is_side_by_side_replacement(tables, &parent_bbox, &parents) {
continue;
}
let mut children =
layout_children_for_parent(tables, native_count, tables[parents[0]].page_number, &parent_bbox);
children.retain(|child| !used_layout.contains(child));
if children.len() != 2
|| !is_side_by_side_replacement(tables, &parent_bbox, &children)
|| !replacement_children_correspond(tables, &parents, &children)
{
continue;
}
used_native.extend(parents.iter().copied());
used_layout.extend(children.iter().copied());
replacements.push((parents, children));
}
replacements
}
fn native_candidate_rows(tables: &[crate::types::Table], native_count: usize) -> Vec<Vec<usize>> {
let mut candidates: Vec<_> = (0..native_count)
.filter(|&index| tables[index].bounding_box.is_some())
.collect();
candidates.sort_by(|&left, &right| {
tables[left]
.page_number
.cmp(&tables[right].page_number)
.then_with(|| table_top(tables, right).total_cmp(&table_top(tables, left)))
.then_with(|| table_left(tables, left).total_cmp(&table_left(tables, right)))
});
let mut rows: Vec<Vec<usize>> = Vec::new();
for candidate in candidates {
let joins_last_row = rows.last().is_some_and(|row| {
tables[row[0]].page_number == tables[candidate].page_number
&& vertical_overlap_fraction(
tables[row[0]].bounding_box.as_ref().expect("candidate has bbox"),
tables[candidate].bounding_box.as_ref().expect("candidate has bbox"),
) >= SIDE_BY_SIDE_VERTICAL_OVERLAP
});
if joins_last_row {
rows.last_mut().expect("row exists").push(candidate);
} else {
rows.push(vec![candidate]);
}
}
for row in &mut rows {
row.sort_by(|&left, &right| table_left(tables, left).total_cmp(&table_left(tables, right)));
}
rows
}
fn replacement_children_correspond(tables: &[crate::types::Table], parents: &[usize], children: &[usize]) -> bool {
parents
.iter()
.zip(children)
.enumerate()
.all(|(position, (&parent, &child))| {
let parent_bbox = tables[parent].bounding_box.as_ref().expect("candidate has bbox");
let child_bbox = tables[child].bounding_box.as_ref().expect("candidate has bbox");
let paired_intersection = bbox_intersection_area(parent_bbox, child_bbox);
let sibling_intersection = children
.get(1 - position)
.and_then(|&sibling| tables[sibling].bounding_box.as_ref())
.map_or(0.0, |sibling_bbox| bbox_intersection_area(parent_bbox, sibling_bbox));
bbox_center_x(child_bbox) >= parent_bbox.x0
&& bbox_center_x(child_bbox) <= parent_bbox.x1
&& paired_intersection > sibling_intersection
&& bbox_overlap_fraction(child_bbox, parent_bbox) + SIDE_BY_SIDE_CORRESPONDENCE_EPSILON
>= SIDE_BY_SIDE_CHILD_PARENT_OVERLAP
&& bbox_overlap_fraction(parent_bbox, child_bbox) + SIDE_BY_SIDE_CORRESPONDENCE_EPSILON
>= SIDE_BY_SIDE_CHILD_PARENT_OVERLAP
})
}
fn bbox_center_x(bbox: &crate::types::BoundingBox) -> f64 {
(bbox.x0 + bbox.x1) / 2.0
}
fn table_top(tables: &[crate::types::Table], index: usize) -> f64 {
tables[index]
.bounding_box
.as_ref()
.map_or(f64::NEG_INFINITY, |bbox| bbox.y1)
}
fn layout_children_for_parent(
tables: &[crate::types::Table],
native_count: usize,
page_number: u32,
parent_bbox: &crate::types::BoundingBox,
) -> Vec<usize> {
let children = (native_count..tables.len())
.filter(|&child| {
tables[child].page_number == page_number
&& tables[child]
.bounding_box
.as_ref()
.is_some_and(|bbox| bbox_overlap_fraction(bbox, parent_bbox) >= SIDE_BY_SIDE_CHILD_PARENT_OVERLAP)
})
.collect();
deduplicate_layout_candidates(tables, children)
}
fn table_union_bbox(left: &crate::types::Table, right: &crate::types::Table) -> Option<crate::types::BoundingBox> {
let left = left.bounding_box.as_ref()?;
let right = right.bounding_box.as_ref()?;
Some(crate::types::BoundingBox {
x0: left.x0.min(right.x0),
y0: left.y0.min(right.y0),
x1: left.x1.max(right.x1),
y1: left.y1.max(right.y1),
})
}
fn deduplicate_layout_candidates(tables: &[crate::types::Table], mut candidates: Vec<usize>) -> Vec<usize> {
candidates.sort_by(|&left, &right| table_left(tables, left).total_cmp(&table_left(tables, right)));
let mut unique: Vec<usize> = Vec::with_capacity(candidates.len());
for candidate in candidates {
let duplicate = unique.iter().position(|&existing| {
let candidate_bbox = tables[candidate].bounding_box.as_ref().expect("candidate has bbox");
let existing_bbox = tables[existing].bounding_box.as_ref().expect("candidate has bbox");
bbox_overlap_fraction(candidate_bbox, existing_bbox) >= LAYOUT_CHILD_DUPLICATE_OVERLAP
&& bbox_overlap_fraction(existing_bbox, candidate_bbox) >= LAYOUT_CHILD_DUPLICATE_OVERLAP
});
if let Some(position) = duplicate {
let existing = unique[position];
if table_content_weight(&tables[candidate]) > table_content_weight(&tables[existing]) {
unique[position] = candidate;
}
} else {
unique.push(candidate);
}
}
unique.sort_by(|&left, &right| table_left(tables, left).total_cmp(&table_left(tables, right)));
unique
}
fn lower_content_table(tables: &[crate::types::Table], left: usize, right: usize) -> usize {
if table_content_weight(&tables[left]) >= table_content_weight(&tables[right]) {
right
} else {
left
}
}
fn table_content_weight(table: &crate::types::Table) -> usize {
table.cells.len() + table.markdown.len()
}
fn is_side_by_side_replacement(
tables: &[crate::types::Table],
parent: &crate::types::BoundingBox,
children: &[usize],
) -> bool {
let parent_width = parent.x1 - parent.x0;
let parent_height = parent.y1 - parent.y0;
if children.len() < 2 || parent_width <= 0.0 || parent_height <= 0.0 {
return false;
}
let horizontally_disjoint = children.windows(2).all(|pair| {
let left = tables[pair[0]].bounding_box.as_ref().expect("candidate has bbox");
let right = tables[pair[1]].bounding_box.as_ref().expect("candidate has bbox");
left.x1 <= right.x0 && vertical_overlap_fraction(left, right) >= SIDE_BY_SIDE_VERTICAL_OVERLAP
});
if !horizontally_disjoint {
return false;
}
let covers_parent_height = children.iter().all(|&index| {
let bbox = tables[index].bounding_box.as_ref().expect("candidate has bbox");
let covered_height = (bbox.y1.min(parent.y1) - bbox.y0.max(parent.y0)).max(0.0);
covered_height / parent_height >= SIDE_BY_SIDE_PARENT_HEIGHT_COVERAGE
});
if !covers_parent_height {
return false;
}
let covered_width: f64 = children
.iter()
.map(|&index| {
let bbox = tables[index].bounding_box.as_ref().expect("candidate has bbox");
(bbox.x1.min(parent.x1) - bbox.x0.max(parent.x0)).max(0.0)
})
.sum();
let covered_area: f64 = children
.iter()
.map(|&index| bbox_intersection_area(tables[index].bounding_box.as_ref().expect("candidate has bbox"), parent))
.sum();
covered_width / parent_width >= SIDE_BY_SIDE_PARENT_WIDTH_COVERAGE
&& covered_area / (parent_width * parent_height) >= SIDE_BY_SIDE_PARENT_AREA_COVERAGE
}
fn bbox_overlap_fraction(child: &crate::types::BoundingBox, parent: &crate::types::BoundingBox) -> f64 {
let child_area = (child.x1 - child.x0).max(0.0) * (child.y1 - child.y0).max(0.0);
if child_area == 0.0 {
return 0.0;
}
bbox_intersection_area(child, parent) / child_area
}
fn bbox_intersection_area(a: &crate::types::BoundingBox, b: &crate::types::BoundingBox) -> f64 {
let intersection_width = (a.x1.min(b.x1) - a.x0.max(b.x0)).max(0.0);
let intersection_height = (a.y1.min(b.y1) - a.y0.max(b.y0)).max(0.0);
intersection_width * intersection_height
}
fn vertical_overlap_fraction(a: &crate::types::BoundingBox, b: &crate::types::BoundingBox) -> f64 {
let overlap = (a.y1.min(b.y1) - a.y0.max(b.y0)).max(0.0);
let min_height = (a.y1 - a.y0).min(b.y1 - b.y0);
if min_height <= 0.0 { 0.0 } else { overlap / min_height }
}
fn table_left(tables: &[crate::types::Table], index: usize) -> f64 {
tables[index]
.bounding_box
.as_ref()
.map_or(f64::INFINITY, |bbox| bbox.x0)
}
fn canonicalize_affected_table_rows(
tables: &mut Vec<crate::types::Table>,
affected_rows: Vec<(u32, crate::types::BoundingBox)>,
) {
let cohorts = affected_row_cohorts(affected_rows);
if cohorts.is_empty() {
return;
}
let assignments: Vec<_> = tables.iter().map(|table| table_row_cohort(table, &cohorts)).collect();
let mut cohort_tables: Vec<Vec<_>> = (0..cohorts.len()).map(|_| Vec::new()).collect();
let mut source: Vec<Option<_>> = std::mem::take(tables).into_iter().map(Some).collect();
for (index, cohort) in assignments.iter().enumerate() {
if let Some(cohort) = cohort {
cohort_tables[*cohort].push(source[index].take().expect("assigned table is present"));
}
}
for cohort in &mut cohort_tables {
cohort.sort_by(canonical_table_order);
cohort.reverse();
}
for (index, cohort) in assignments.iter().enumerate() {
if let Some(cohort) = cohort {
source[index] = Some(
cohort_tables[*cohort]
.pop()
.expect("cohort table count matches assigned slots"),
);
}
}
tables.extend(source.into_iter().flatten());
}
fn affected_row_cohorts(mut rows: Vec<(u32, crate::types::BoundingBox)>) -> Vec<(u32, Vec<crate::types::BoundingBox>)> {
let mut cohorts = Vec::new();
while let Some((page, seed)) = rows.pop() {
let mut cohort = vec![seed];
let mut changed = true;
while changed {
changed = false;
rows.retain(|(candidate_page, candidate)| {
let connected = *candidate_page == page
&& cohort
.iter()
.any(|member| vertical_overlap_fraction(member, candidate) >= SIDE_BY_SIDE_VERTICAL_OVERLAP);
if connected {
cohort.push(*candidate);
changed = true;
}
!connected
});
}
cohorts.push((page, cohort));
}
cohorts.sort_by(|(left_page, left_rows), (right_page, right_rows)| {
left_page.cmp(right_page).then_with(|| {
let left_y = left_rows.iter().map(|row| row.y0).fold(f64::INFINITY, f64::min);
let right_y = right_rows.iter().map(|row| row.y0).fold(f64::INFINITY, f64::min);
left_y.total_cmp(&right_y)
})
});
cohorts
}
fn table_row_cohort(table: &crate::types::Table, cohorts: &[(u32, Vec<crate::types::BoundingBox>)]) -> Option<usize> {
let bbox = table.bounding_box.as_ref()?;
cohorts
.iter()
.enumerate()
.filter(|(_, (page, _))| *page == table.page_number)
.map(|(index, (_, rows))| {
let overlap = rows
.iter()
.map(|row| vertical_overlap_fraction(bbox, row))
.fold(0.0_f64, f64::max);
(index, overlap)
})
.filter(|(_, overlap)| *overlap >= SIDE_BY_SIDE_VERTICAL_OVERLAP)
.max_by(|left, right| left.1.total_cmp(&right.1).then_with(|| right.0.cmp(&left.0)))
.map(|(index, _)| index)
}
fn canonical_table_order(left: &crate::types::Table, right: &crate::types::Table) -> std::cmp::Ordering {
let left_bbox = left.bounding_box.as_ref();
let right_bbox = right.bounding_box.as_ref();
left.page_number
.cmp(&right.page_number)
.then_with(|| {
left_bbox
.map_or(f64::INFINITY, |bbox| bbox.y0)
.total_cmp(&right_bbox.map_or(f64::INFINITY, |bbox| bbox.y0))
})
.then_with(|| {
left_bbox
.map_or(f64::INFINITY, |bbox| bbox.x0)
.total_cmp(&right_bbox.map_or(f64::INFINITY, |bbox| bbox.x0))
})
.then_with(|| left.markdown.cmp(&right.markdown))
}
pub(crate) fn un_mark_layout_furniture_per_config(
paragraphs: &mut [PdfParagraph],
include_headers: bool,
include_footers: bool,
include_footnotes: bool,
) {
if !include_headers && !include_footers && !include_footnotes {
return;
}
for para in paragraphs.iter_mut() {
if !para.is_page_furniture {
continue;
}
match para.layout_class {
Some(super::types::LayoutHintClass::PageHeader) if include_headers => {
para.is_page_furniture = false;
}
Some(super::types::LayoutHintClass::PageFooter) if include_footers => {
para.is_page_furniture = false;
}
Some(super::types::LayoutHintClass::Footnote) if include_footnotes => {
para.is_page_furniture = false;
}
_ => {}
}
}
}
const FOOTNOTE_MARKER_MAX_CHARS: usize = 3;
const FOOTNOTE_MARKER_MAX_FONT_RATIO: f32 = 0.8;
const FOOTNOTE_MARKER_MAX_GAP_EM: f32 = 0.5;
const FOOTNOTE_MARKER_MIN_VERTICAL_OVERLAP_RATIO: f32 = 0.8;
const FOOTNOTE_MARKER_MIN_RISE_EM: f32 = 0.1;
const FOOTNOTE_RUN_MIN_LENGTH: usize = 3;
const FOOTNOTE_RUN_MAX_FONT_DELTA: f32 = 0.5;
const FOOTNOTE_RUN_MAX_LEFT_DELTA_EM: f32 = 0.5;
const FOOTNOTE_RUN_MAX_VERTICAL_GAP_EM: f32 = 0.5;
const FOOTNOTE_RUN_MAX_GAP_SPREAD_EM: f32 = 0.25;
fn merge_spatial_footnote_markers(paragraphs: &mut Vec<PdfParagraph>) {
let mut merged_pairs = vec![false; paragraphs.len()];
let mut index = 0;
while index + 1 < paragraphs.len() {
if !is_spatial_footnote_pair(¶graphs[index], ¶graphs[index + 1]) {
index += 1;
continue;
}
let marker = paragraphs.remove(index);
merged_pairs.remove(index);
let body = &mut paragraphs[index];
let mut lines = marker.lines;
lines.append(&mut body.lines);
body.lines = lines;
body.text.clear();
body.block_bbox = marker.block_bbox.zip(body.block_bbox).map(|(marker_bbox, body_bbox)| {
(
marker_bbox.0.min(body_bbox.0),
marker_bbox.1.min(body_bbox.1),
marker_bbox.2.max(body_bbox.2),
marker_bbox.3.max(body_bbox.3),
)
});
body.word_count = PdfParagraph::compute_word_count("", &body.lines);
body.is_page_furniture = false;
merged_pairs[index] = true;
index += 1;
}
merge_consecutive_spatial_footnotes(paragraphs, &mut merged_pairs);
}
fn spatial_footnote_number(paragraph: &PdfParagraph) -> Option<u32> {
if paragraph.lines.len() < 2 || paragraph.heading_level.is_some() || paragraph.is_list_item {
return None;
}
let marker = paragraph.lines.first()?.segments.first()?;
if marker.font_size > paragraph.dominant_font_size * FOOTNOTE_MARKER_MAX_FONT_RATIO {
return None;
}
marker.text.trim().parse().ok()
}
fn spatial_footnote_gap(upper: &PdfParagraph, lower: &PdfParagraph) -> Option<f32> {
let (_, upper_bottom, _, _) = upper.block_bbox?;
let (_, _, _, lower_top) = lower.block_bbox?;
Some(upper_bottom - lower_top)
}
fn spatial_footnotes_are_adjacent(upper: &PdfParagraph, lower: &PdfParagraph) -> bool {
let Some(upper_number) = spatial_footnote_number(upper) else {
return false;
};
let Some(lower_number) = spatial_footnote_number(lower) else {
return false;
};
let Some((upper_left, _, _, _)) = upper.block_bbox else {
return false;
};
let Some((lower_left, _, _, _)) = lower.block_bbox else {
return false;
};
let Some(gap) = spatial_footnote_gap(upper, lower) else {
return false;
};
upper_number.checked_add(1) == Some(lower_number)
&& upper.layout_region_path == lower.layout_region_path
&& (upper.dominant_font_size - lower.dominant_font_size).abs() < FOOTNOTE_RUN_MAX_FONT_DELTA
&& (upper_left - lower_left).abs() <= upper.dominant_font_size * FOOTNOTE_RUN_MAX_LEFT_DELTA_EM
&& gap >= 0.0
&& gap <= upper.dominant_font_size * FOOTNOTE_RUN_MAX_VERTICAL_GAP_EM
}
fn spatial_footnote_run_is_regular(run: &[PdfParagraph]) -> bool {
if run.len() < FOOTNOTE_RUN_MIN_LENGTH
|| !run
.windows(2)
.all(|pair| spatial_footnotes_are_adjacent(&pair[0], &pair[1]))
{
return false;
}
let gaps = run
.windows(2)
.filter_map(|pair| spatial_footnote_gap(&pair[0], &pair[1]))
.collect::<Vec<_>>();
let minimum = gaps.iter().copied().fold(f32::INFINITY, f32::min);
let maximum = gaps.iter().copied().fold(f32::NEG_INFINITY, f32::max);
maximum - minimum <= run[0].dominant_font_size * FOOTNOTE_RUN_MAX_GAP_SPREAD_EM
}
fn merge_spatial_footnote_run(run: Vec<PdfParagraph>) -> PdfParagraph {
let mut iter = run.into_iter();
let mut merged = iter.next().expect("footnote run is non-empty");
for mut paragraph in iter {
merged.lines.append(&mut paragraph.lines);
merged.block_bbox = merged.block_bbox.zip(paragraph.block_bbox).map(|(left, right)| {
(
left.0.min(right.0),
left.1.min(right.1),
left.2.max(right.2),
left.3.max(right.3),
)
});
}
merged.text.clear();
merged.word_count = PdfParagraph::compute_word_count("", &merged.lines);
merged
}
fn merge_consecutive_spatial_footnotes(paragraphs: &mut Vec<PdfParagraph>, merged_pairs: &mut Vec<bool>) {
let mut start = 0;
while start + FOOTNOTE_RUN_MIN_LENGTH <= paragraphs.len() {
let minimum_end = start + FOOTNOTE_RUN_MIN_LENGTH;
if !merged_pairs[start..minimum_end].iter().all(|merged| *merged)
|| !spatial_footnote_run_is_regular(¶graphs[start..minimum_end])
{
start += 1;
continue;
}
let mut end = minimum_end;
while end < paragraphs.len() && merged_pairs[end] && spatial_footnote_run_is_regular(¶graphs[start..=end]) {
end += 1;
}
let merged = merge_spatial_footnote_run(paragraphs.drain(start..end).collect());
paragraphs.insert(start, merged);
merged_pairs.drain(start..end);
merged_pairs.insert(start, false);
start += 1;
}
}
fn is_spatial_footnote_pair(marker: &PdfParagraph, body: &PdfParagraph) -> bool {
if !marker.is_page_furniture
|| body.is_page_furniture
|| body.heading_level.is_some()
|| body.is_list_item
|| body.is_code_block
|| body.is_formula
|| body.caption_for.is_some()
|| marker.layout_region_path != body.layout_region_path
|| !is_compact_footnote_marker(¶graph_text_raw(marker))
{
return false;
}
let Some((marker_left, marker_bottom, marker_right, marker_top)) = marker.block_bbox else {
return false;
};
let Some((body_left, body_bottom, _, body_top)) = body.block_bbox else {
return false;
};
let marker_height = marker_top - marker_bottom;
let body_height = body_top - body_bottom;
let overlap = marker_top.min(body_top) - marker_bottom.max(body_bottom);
let minimum_height = marker_height.min(body_height);
let horizontal_gap = body_left - marker_right;
marker_left < body_left
&& horizontal_gap >= 0.0
&& horizontal_gap <= body.dominant_font_size * FOOTNOTE_MARKER_MAX_GAP_EM
&& marker.dominant_font_size <= body.dominant_font_size * FOOTNOTE_MARKER_MAX_FONT_RATIO
&& marker_bottom - body_bottom >= body.dominant_font_size * FOOTNOTE_MARKER_MIN_RISE_EM
&& minimum_height > 0.0
&& overlap >= minimum_height * FOOTNOTE_MARKER_MIN_VERTICAL_OVERLAP_RATIO
}
fn is_compact_footnote_marker(text: &str) -> bool {
let marker = text.trim();
let char_count = marker.chars().count();
char_count > 0
&& char_count <= FOOTNOTE_MARKER_MAX_CHARS
&& (marker.chars().all(|character| character.is_ascii_digit())
|| marker
.chars()
.all(|character| matches!(character, '*' | '†' | '‡' | '§')))
}
const MAX_PAGE_NUMBER_WORD_COUNT: usize = 10;
const FALLBACK_PAGE_WIDTH_PTS: f32 = 612.0;
const FALLBACK_PAGE_HEIGHT_PTS: f32 = 792.0;
fn mark_validated_page_numbers(all_pages: &mut [Vec<PdfParagraph>], page_heights: &[f32]) {
use super::page_number::{PageNumberSequence, margin_band};
let page_width = document_content_width(all_pages);
let mut sequence = PageNumberSequence::new();
let mut observations: Vec<(usize, usize, f32, f32)> = Vec::new();
for (page_index, page) in all_pages.iter().enumerate() {
let page_height = page_heights
.get(page_index)
.copied()
.unwrap_or(FALLBACK_PAGE_HEIGHT_PTS);
for (paragraph_index, paragraph) in page.iter().enumerate() {
let Some((y_ratio, x_ratio, candidate)) = page_number_observation(paragraph, page_height, page_width)
else {
continue;
};
sequence.observe(page_index, margin_band(y_ratio), x_ratio, &candidate);
observations.push((page_index, paragraph_index, y_ratio, x_ratio));
}
}
let mut confirmed = 0_usize;
for (page_index, paragraph_index, y_ratio, x_ratio) in observations {
let band = margin_band(y_ratio);
if matches!(band, super::page_number::MarginBand::Body) {
continue;
}
if sequence.confidence_at(page_index, band, x_ratio) < PageNumberSequence::DELETION_THRESHOLD {
continue;
}
if let Some(paragraph) = all_pages
.get_mut(page_index)
.and_then(|page| page.get_mut(paragraph_index))
{
paragraph.is_page_furniture = true;
confirmed += 1;
}
}
tracing::debug!(
pages = all_pages.len(),
confirmed,
"page-number furniture confirmed by position and cross-page sequence"
);
}
fn layout_class_permits_page_number_deletion(paragraph: &PdfParagraph) -> bool {
paragraph.layout_class.is_none()
}
fn page_number_observation(
paragraph: &PdfParagraph,
page_height: f32,
page_width: f32,
) -> Option<(f32, f32, super::page_number::PageNumberCandidate)> {
if paragraph.heading_level.is_some()
|| paragraph.is_list_item
|| paragraph.is_code_block
|| paragraph.is_page_furniture
|| paragraph.word_count > MAX_PAGE_NUMBER_WORD_COUNT
{
return None;
}
if !layout_class_permits_page_number_deletion(paragraph) {
return None;
}
let text = paragraph_text_raw(paragraph);
let candidate = super::page_number::classify_page_number_text(text.trim())?;
let (y_ratio, x_ratio) = paragraph_position_ratios(paragraph, page_height, page_width)?;
Some((y_ratio, x_ratio, candidate))
}
fn paragraph_position_ratios(paragraph: &PdfParagraph, page_height: f32, page_width: f32) -> Option<(f32, f32)> {
if !page_height.is_finite() || page_height <= 0.0 || !page_width.is_finite() || page_width <= 0.0 {
return None;
}
let (left, bottom, right, top) = finite_paragraph_bbox(paragraph)?;
let centre_y = (bottom + top) * 0.5;
let centre_x = (left + right) * 0.5;
let y_ratio = (1.0 - centre_y / page_height).clamp(0.0, 1.0);
let x_ratio = (centre_x / page_width).clamp(0.0, 1.0);
Some((y_ratio, x_ratio))
}
fn finite_paragraph_bbox(paragraph: &PdfParagraph) -> Option<(f32, f32, f32, f32)> {
let bbox = paragraph_geometry_bbox(paragraph)?;
let (left, bottom, right, top) = bbox;
(left.is_finite() && bottom.is_finite() && right.is_finite() && top.is_finite()).then_some(bbox)
}
fn document_content_width(all_pages: &[Vec<PdfParagraph>]) -> f32 {
let widest = all_pages
.iter()
.flatten()
.filter_map(finite_paragraph_bbox)
.map(|(_, _, right, _)| right)
.filter(|right| *right > 0.0)
.fold(0.0_f32, f32::max);
if widest > 0.0 { widest } else { FALLBACK_PAGE_WIDTH_PTS }
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn strip_repeating_text_from_pages(pages: &mut [Vec<PdfParagraph>], page_heights: &[f32]) {
mark_cross_page_repeating_text(pages, page_heights);
mark_cross_page_repeating_short_text(pages);
for page in pages.iter_mut() {
retain_page_furniture_safely(page);
}
deduplicate_paragraphs(pages, &ahash::AHashMap::new());
}
fn retain_page_furniture_safely(paragraphs: &mut Vec<PdfParagraph>) {
let total = paragraphs.len();
let furniture_count = paragraphs.iter().filter(|p| p.is_page_furniture).count();
if furniture_count == 0 {
return;
}
if furniture_count >= total {
for para in paragraphs.iter_mut() {
para.is_page_furniture = false;
}
return;
}
let total_alphanum: usize = paragraphs.iter().map(paragraph_alphanum_len).sum();
if total_alphanum > 0 {
let furniture_alphanum: usize = paragraphs
.iter()
.filter(|p| p.is_page_furniture)
.map(paragraph_alphanum_len)
.sum();
if furniture_alphanum * 100 > total_alphanum * 30 {
for para in paragraphs.iter_mut() {
para.is_page_furniture = false;
}
return;
}
}
const MIN_SUBSTANTIVE_CHARS: usize = 80;
paragraphs.retain(|p| {
if !p.is_page_furniture {
return true;
}
paragraph_alphanum_len(p) > MIN_SUBSTANTIVE_CHARS
});
}
fn paragraph_alphanum_len(para: &PdfParagraph) -> usize {
para.lines
.iter()
.flat_map(|line| line.segments.iter())
.map(|seg| seg.text.bytes().filter(|b| b.is_ascii_alphanumeric()).count())
.sum()
}
fn dehyphenate_paragraphs(paragraphs: &mut [PdfParagraph], has_positions: bool, hyphen_witnesses: &HyphenWitnesses) {
for para in paragraphs.iter_mut() {
if para.is_code_block || para.lines.len() < 2 {
continue;
}
if has_positions {
dehyphenate_paragraph_lines(para, hyphen_witnesses);
} else {
dehyphenate_hyphen_only(para, hyphen_witnesses);
}
}
}
const PRESERVED_LEXICAL_COMPOUNDS: &[(&str, &str)] = &[
("cost", "effective"),
("evidence", "based"),
("high", "level"),
("long", "term"),
("low", "level"),
("real", "time"),
("short", "term"),
("state", "of-the-art"),
("user", "defined"),
("well", "known"),
];
const MIN_HYPHEN_WITNESS_WORD_LEN: usize = 2;
fn collect_hyphen_witnesses(all_page_segments: &[Vec<SegmentData>]) -> HyphenWitnesses {
let mut witnesses = HyphenWitnesses::default();
for segment in all_page_segments.iter().flatten() {
let characters: Vec<char> = segment.text.chars().collect();
if characters.len() < 3 {
continue;
}
for position in 1..characters.len() - 1 {
if characters[position] != '-' {
continue;
}
let left: String = characters[..position]
.iter()
.rev()
.take_while(|character| character.is_alphabetic())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let right: String = characters[position + 1..]
.iter()
.take_while(|character| character.is_alphabetic())
.collect();
let left_len = left.chars().count();
let right_len = right.chars().count();
if left_len < MIN_HYPHEN_WITNESS_WORD_LEN || right_len < MIN_HYPHEN_WITNESS_WORD_LEN {
continue;
}
witnesses.insert((left.to_ascii_lowercase(), right.to_ascii_lowercase()));
}
}
witnesses
}
fn collect_word_witnesses(all_page_segments: &[Vec<SegmentData>]) -> WordWitnesses {
let mut witnesses = WordWitnesses::default();
for segment in all_page_segments.iter().flatten() {
let cores: Vec<&str> = segment
.text
.split_whitespace()
.map(|token| token.trim_matches(|c: char| !c.is_alphabetic()))
.collect();
for index in 0..cores.len() {
let core = cores[index];
if core.chars().count() < MIN_LIGATURE_WITNESS_WORD_LEN {
continue;
}
let is_left_of_candidate = core.ends_with('f')
&& cores
.get(index + 1)
.and_then(|next| next.chars().next())
.is_some_and(|c| matches!(c, 'i' | 'l' | 'f'));
let is_right_of_candidate = index > 0
&& cores[index - 1].ends_with('f')
&& core.chars().next().is_some_and(|c| matches!(c, 'i' | 'l' | 'f'));
if is_left_of_candidate || is_right_of_candidate {
continue;
}
witnesses.insert(core.to_ascii_lowercase());
}
}
witnesses
}
pub(super) fn should_preserve_lexical_hyphen(
trailing_word: &str,
leading_word: &str,
hyphen_witnesses: &HyphenWitnesses,
) -> bool {
let trim_non_lexical = |ch: char| !ch.is_alphanumeric() && ch != '-';
let left = trailing_word.trim_matches(trim_non_lexical);
let right = leading_word.trim_matches(trim_non_lexical);
let matches_static_compound = PRESERVED_LEXICAL_COMPOUNDS
.iter()
.any(|&(expected_left, expected_right)| {
left.eq_ignore_ascii_case(expected_left) && right.eq_ignore_ascii_case(expected_right)
});
matches_static_compound || hyphen_witnesses.contains(&(left.to_ascii_lowercase(), right.to_ascii_lowercase()))
}
fn spans_visual_line_break(trailing: &SegmentData, leading: &SegmentData) -> bool {
if !trailing.has_same_rotation(leading) {
return false;
}
let trailing_baseline = trailing.upright_baseline();
let leading_baseline = leading.upright_baseline();
trailing_baseline.is_finite()
&& leading_baseline.is_finite()
&& (trailing_baseline - leading_baseline).abs() > INLINE_STYLE_BASELINE_TOLERANCE
}
fn dehyphenate_paragraph_lines(para: &mut PdfParagraph, hyphen_witnesses: &HyphenWitnesses) {
let max_right_edge = para
.lines
.iter()
.flat_map(|l| l.segments.iter())
.map(|s| s.x + s.width)
.fold(0.0_f32, f32::max);
if max_right_edge <= 0.0 {
dehyphenate_hyphen_only(para, hyphen_witnesses);
return;
}
let threshold = max_right_edge * FULL_LINE_FRACTION;
let n = para.lines.len();
for i in 0..(n - 1) {
let trailing_right = para.lines[i].segments.last().map(|s| s.x + s.width).unwrap_or(0.0);
if trailing_right < threshold {
continue;
}
let crosses_visual_line = match (para.lines[i].segments.last(), para.lines[i + 1].segments.first()) {
(Some(trailing), Some(leading)) => spans_visual_line_break(trailing, leading),
_ => false,
};
if !crosses_visual_line {
continue;
}
let trailing_text = match para.lines[i].segments.last() {
Some(s) if !s.text.is_empty() => s.text.clone(),
_ => continue,
};
let leading_text = match para.lines[i + 1].segments.first() {
Some(s) if !s.text.is_empty() => s.text.clone(),
_ => continue,
};
let has_trailing_hyphen = trailing_text.ends_with('-');
if !has_trailing_hyphen {
continue;
}
let leading_word = leading_text.split_whitespace().next().unwrap_or("");
if leading_word.chars().next().is_some_and(|c| c.is_uppercase()) {
continue;
}
let trailing_word = trailing_text
.trim_end_matches('-')
.split_whitespace()
.last()
.unwrap_or("");
if trailing_word.chars().last().is_some_and(is_cjk_char) {
continue;
}
let preserved_hyphen = if should_preserve_lexical_hyphen(trailing_word, leading_word, hyphen_witnesses) {
"-"
} else {
""
};
let joined_word = format!("{trailing_word}{preserved_hyphen}{leading_word}");
if let Some(seg) = para.lines[i].segments.last_mut() {
let text_without_word: String = seg
.text
.chars()
.rev()
.skip(trailing_word.len() + 1)
.collect::<String>()
.chars()
.rev()
.collect();
seg.text = format!("{text_without_word}{joined_word}");
}
if let Some(seg) = para.lines[i + 1].segments.first_mut() {
let after_leading_word = seg.text.trim_start_matches(leading_word).trim_start();
seg.text = after_leading_word.to_string();
}
}
}
fn dehyphenate_hyphen_only(para: &mut PdfParagraph, hyphen_witnesses: &HyphenWitnesses) {
let n = para.lines.len();
for i in 0..(n - 1) {
let crosses_visual_line = match (para.lines[i].segments.last(), para.lines[i + 1].segments.first()) {
(Some(trailing), Some(leading)) => spans_visual_line_break(trailing, leading),
_ => false,
};
if !crosses_visual_line {
continue;
}
let trailing_text = match para.lines[i].segments.last() {
Some(s) if s.text.ends_with('-') => s.text.clone(),
_ => continue,
};
let leading_text = match para.lines[i + 1].segments.first() {
Some(s) if !s.text.is_empty() => s.text.clone(),
_ => continue,
};
let leading_word = leading_text.split_whitespace().next().unwrap_or("");
if leading_word.chars().next().is_some_and(|c| c.is_uppercase()) {
continue;
}
let trailing_word = trailing_text
.trim_end_matches('-')
.split_whitespace()
.last()
.unwrap_or("");
if trailing_word.chars().last().is_some_and(is_cjk_char) {
continue;
}
let preserved_hyphen = if should_preserve_lexical_hyphen(trailing_word, leading_word, hyphen_witnesses) {
"-"
} else {
""
};
let joined_word = format!("{trailing_word}{preserved_hyphen}{leading_word}");
if let Some(seg) = para.lines[i].segments.last_mut() {
let text_without_word: String = seg
.text
.chars()
.rev()
.skip(trailing_word.len() + 1)
.collect::<String>()
.chars()
.rev()
.collect();
seg.text = format!("{text_without_word}{joined_word}");
}
if let Some(seg) = para.lines[i + 1].segments.first_mut() {
let after_leading_word = seg.text.trim_start_matches(leading_word).trim_start();
seg.text = after_leading_word.to_string();
}
}
}
fn has_font_size_variation(paragraphs: &[PdfParagraph]) -> bool {
let mut first_size: Option<f32> = None;
for para in paragraphs {
let size = para.dominant_font_size;
if size <= 0.0 {
continue;
}
match first_size {
None => first_size = Some(size),
Some(fs) if (size - fs).abs() > 0.5 => return true,
_ => {}
}
}
false
}
fn deduplicate_paragraphs(
all_pages: &mut [Vec<PdfParagraph>],
table_coverage_by_page: &ahash::AHashMap<usize, Vec<TableCoverage>>,
) {
for (page_index, page) in all_pages.iter_mut().enumerate() {
if page.len() < 2 {
continue;
}
let mut i = 0;
while i + 1 < page.len() {
let a_text = paragraph_text_normalized(&page[i]);
let b_text = paragraph_text_normalized(&page[i + 1]);
if page[i].layout_region_path == page[i + 1].layout_region_path && a_text.len() >= 5 && a_text == b_text {
page.remove(i + 1);
} else {
i += 1;
}
}
let Some(page_tables) = table_coverage_by_page
.get(&page_index)
.filter(|tables| !tables.is_empty())
else {
continue;
};
let mut seen = ahash::AHashSet::new();
let mut to_remove = Vec::new();
for (idx, para) in page.iter().enumerate() {
if !is_dedup_candidate(para) {
continue;
}
let text = paragraph_text_whitespace_collapsed(para);
if text.len() < 15 {
continue;
}
let glyphs = normalize_for_table_coverage(&text);
if glyphs.is_empty() || !page_tables.iter().any(|table| table.cell_text.contains(&glyphs)) {
continue;
}
if !seen.insert((para.layout_region_path, text)) {
to_remove.push(idx);
}
}
for &idx in to_remove.iter().rev() {
page.remove(idx);
}
}
}
const DEFAULT_OUTLINE_HEADING_OFFSET: i64 = 2;
const MIN_OUTLINE_CALIBRATION_ANCHORS: usize = 2;
const MIN_MARKDOWN_HEADING_LEVEL: i64 = 1;
const MAX_MARKDOWN_HEADING_LEVEL: i64 = 6;
#[derive(Debug, Clone, Copy)]
struct OutlineParagraphMatch {
page_index: usize,
paragraph_index: usize,
depth: usize,
}
fn recover_headings_from_outline(all_pages: &mut [Vec<PdfParagraph>], outline_entries: &[PdfOutlineEntry]) {
let matches = collect_unique_outline_matches(all_pages, outline_entries);
let offset = calibrated_outline_heading_offset(all_pages, &matches);
for matched in matches {
let paragraph = &mut all_pages[matched.page_index][matched.paragraph_index];
if paragraph.heading_level.is_some() || !outline_layout_allows_heading(paragraph) {
continue;
}
let depth = i64::try_from(matched.depth).unwrap_or(i64::MAX);
let level = depth
.saturating_add(offset)
.clamp(MIN_MARKDOWN_HEADING_LEVEL, MAX_MARKDOWN_HEADING_LEVEL);
paragraph.heading_level = Some(level as u8);
paragraph.is_list_item = false;
paragraph.is_page_furniture = false;
}
}
fn collect_unique_outline_matches(
all_pages: &[Vec<PdfParagraph>],
outline_entries: &[PdfOutlineEntry],
) -> Vec<OutlineParagraphMatch> {
let mut outline_counts = ahash::AHashMap::<(usize, String), usize>::new();
for entry in outline_entries {
if let Some(key) = outline_match_key(entry, all_pages.len()) {
*outline_counts.entry(key).or_default() += 1;
}
}
let paragraph_matches = all_pages
.iter()
.map(|page| {
let mut matches = ahash::AHashMap::<String, (usize, usize)>::new();
for (index, paragraph) in page.iter().enumerate() {
let title = normalize_outline_title(¶graph_text_raw(paragraph));
let entry = matches.entry(title).or_insert((0, index));
entry.0 += 1;
}
matches
})
.collect::<Vec<_>>();
outline_entries
.iter()
.filter_map(|entry| {
let (page_index, title) = outline_match_key(entry, all_pages.len())?;
if outline_counts.get(&(page_index, title.clone())) != Some(&1) {
return None;
}
let &(paragraph_count, paragraph_index) = paragraph_matches[page_index].get(&title)?;
(paragraph_count == 1).then_some(OutlineParagraphMatch {
page_index,
paragraph_index,
depth: entry.depth,
})
})
.collect()
}
fn outline_match_key(entry: &PdfOutlineEntry, page_count: usize) -> Option<(usize, String)> {
let page_number = entry.page_number?;
let page_index = usize::try_from(page_number.checked_sub(1)?).ok()?;
let title = normalize_outline_title(&entry.title);
(page_index < page_count && !title.is_empty()).then_some((page_index, title))
}
fn calibrated_outline_heading_offset(all_pages: &[Vec<PdfParagraph>], matches: &[OutlineParagraphMatch]) -> i64 {
let mut counts = ahash::AHashMap::<i64, usize>::new();
for matched in matches {
let paragraph = &all_pages[matched.page_index][matched.paragraph_index];
if !outline_layout_allows_heading(paragraph) {
continue;
}
if let Some(level) = paragraph.heading_level {
let depth = i64::try_from(matched.depth).unwrap_or(i64::MAX);
*counts.entry(i64::from(level).saturating_sub(depth)).or_default() += 1;
}
}
let max_count = counts.values().copied().max().unwrap_or_default();
let mut winners = counts.into_iter().filter(|(_, count)| *count == max_count);
let winner = winners.next();
match (winner, winners.next(), max_count) {
(Some((offset, _)), None, count) if count >= MIN_OUTLINE_CALIBRATION_ANCHORS => offset,
_ => DEFAULT_OUTLINE_HEADING_OFFSET,
}
}
fn outline_layout_allows_heading(paragraph: &PdfParagraph) -> bool {
if paragraph.is_code_block || paragraph.is_formula || paragraph.caption_for.is_some() {
return false;
}
matches!(
paragraph.layout_class,
None | Some(super::types::LayoutHintClass::Title)
| Some(super::types::LayoutHintClass::SectionHeader)
| Some(super::types::LayoutHintClass::Text)
| Some(super::types::LayoutHintClass::Other)
)
}
fn normalize_outline_title(text: &str) -> String {
let text = strip_section_label(text.trim());
let mut normalized = String::new();
let mut pending_space = false;
for character in text.chars().flat_map(char::to_lowercase) {
if character.is_alphanumeric() {
if pending_space && !normalized.is_empty() {
normalized.push(' ');
}
normalized.push(character);
pending_space = false;
} else if !normalized.is_empty() {
pending_space = true;
}
}
normalized
}
fn strip_section_label(text: &str) -> &str {
let Some((first, rest)) = text.split_once(char::is_whitespace) else {
return text;
};
let punctuated = first
.chars()
.next()
.is_some_and(|character| matches!(character, '(' | '['))
|| first
.chars()
.last()
.is_some_and(|character| matches!(character, '.' | ')' | ']' | ':'));
let core = first.trim_matches(|character| matches!(character, '(' | '[' | '.' | ')' | ']' | ':'));
let decimal_parts = core.split('.').collect::<Vec<_>>();
let decimal = !decimal_parts.is_empty()
&& decimal_parts
.iter()
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
let decimal_label = decimal && (punctuated || decimal_parts.len() > 1 || core.len() <= 3);
let roman_label = punctuated
&& !core.is_empty()
&& core
.chars()
.all(|character| matches!(character.to_ascii_uppercase(), 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'));
let letter_label = punctuated && core.len() == 1 && core.chars().all(|character| character.is_ascii_alphabetic());
if decimal_label || roman_label || letter_label {
rest.trim_start()
} else {
text
}
}
fn paragraph_text_raw(para: &PdfParagraph) -> String {
if para.text.is_empty() {
para.lines
.iter()
.flat_map(|line| line.segments.iter())
.map(|segment| segment.text.as_str())
.collect::<Vec<_>>()
.join(" ")
} else {
para.text.clone()
}
}
fn paragraph_text_normalized(para: &PdfParagraph) -> String {
paragraph_text_raw(para)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn paragraph_text_whitespace_collapsed(para: &PdfParagraph) -> String {
paragraph_text_raw(para)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn is_dedup_candidate(p: &PdfParagraph) -> bool {
p.heading_level.is_none()
&& !p.is_list_item
&& !p.is_code_block
&& !p.is_formula
&& !p.is_page_furniture
&& p.caption_for.is_none()
}
const RUN_IN_LIST_MIN_LEAD_WORDS: usize = 4;
const RUN_IN_LIST_MIN_ITEMS: usize = 2;
const RUN_IN_LIST_MIN_ITEM_WORDS: usize = 3;
fn split_colon_semicolon_run_in_lists(all_page_paragraphs: &mut [Vec<PdfParagraph>]) {
for page_paragraphs in all_page_paragraphs.iter_mut() {
let mut index = 0;
while index < page_paragraphs.len() {
match try_split_run_in_list(&page_paragraphs[index]) {
Some(replacement) => {
let inserted = replacement.len();
page_paragraphs.splice(index..=index, replacement);
index += inserted;
}
None => index += 1,
}
}
}
}
fn try_split_run_in_list(para: &PdfParagraph) -> Option<Vec<PdfParagraph>> {
if para.heading_level.is_some()
|| para.is_list_item
|| para.is_code_block
|| para.is_formula
|| para.is_page_furniture
{
return None;
}
let normalized: String = paragraph_text_raw(para)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let colon_byte = normalized.rfind(':')?;
let lead = normalized[..=colon_byte].trim();
if lead.split_whitespace().count() < RUN_IN_LIST_MIN_LEAD_WORDS {
return None;
}
let tail = normalized[colon_byte + 1..].trim_start();
if tail.is_empty() {
return None;
}
let items: Vec<&str> = tail
.split_inclusive(';')
.map(str::trim)
.filter(|segment| !segment.is_empty())
.collect();
if items.len() < RUN_IN_LIST_MIN_ITEMS || !items.iter().all(|item| is_probable_run_in_list_item(item)) {
return None;
}
let mut split = Vec::with_capacity(items.len() + 1);
split.push(run_in_list_fragment(para, lead.to_string(), false));
for item in items {
split.push(run_in_list_fragment(para, item.to_string(), true));
}
Some(split)
}
fn is_probable_run_in_list_item(item: &str) -> bool {
item.split_whitespace().count() >= RUN_IN_LIST_MIN_ITEM_WORDS
&& item.chars().next().is_some_and(char::is_lowercase)
&& matches!(item.chars().last(), Some(';' | '.'))
}
fn run_in_list_fragment(source: &PdfParagraph, text: String, is_list_item: bool) -> PdfParagraph {
let word_count = text.split_whitespace().count();
PdfParagraph {
text,
lines: Vec::new(),
heading_level: None,
is_list_item,
is_code_block: false,
is_formula: false,
layout_class: if is_list_item {
Some(super::types::LayoutHintClass::ListItem)
} else {
source.layout_class
},
word_count,
..source.clone()
}
}
fn apply_text_repair_to_structure_tree_paragraphs(
paragraphs: &mut Vec<PdfParagraph>,
has_positions: bool,
witnesses: &TextRepairWitnesses,
) {
apply_to_all_segments(paragraphs, |text| fused_text_repairs(text, &witnesses.words));
dehyphenate_paragraphs(paragraphs, has_positions, &witnesses.hyphens);
split_embedded_list_items(paragraphs);
synchronize_paragraph_text_metadata(paragraphs);
}
fn synchronize_paragraph_text_metadata(paragraphs: &mut [PdfParagraph]) {
for paragraph in paragraphs {
paragraph.text.clear();
paragraph.word_count = PdfParagraph::compute_word_count("", ¶graph.lines);
}
}
fn compact_final_heading_hierarchy(all_pages: &mut [Vec<PdfParagraph>]) {
let headings = all_pages
.iter()
.flat_map(|page| page.iter())
.filter_map(|paragraph| paragraph.heading_level);
let (h1_count, has_h2, has_deeper) = headings.fold((0usize, false, false), |state, level| {
(
state.0 + usize::from(level == 1),
state.1 || level == 2,
state.2 || level >= 3,
)
});
if h1_count != 1 || has_h2 || !has_deeper {
return;
}
for paragraph in all_pages.iter_mut().flat_map(|page| page.iter_mut()) {
if let Some(level @ 3..) = paragraph.heading_level {
paragraph.heading_level = Some(level - 1);
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod list_marker_tests;