* feat(vision): expose OCR pipeline * fix(vision): harden OCR API * refactor(vision): expose OCR API
2944 lines
114 KiB
Rust
2944 lines
114 KiB
Rust
//! Markdown conversion with structure detection.
|
||
//!
|
||
//! Converts extracted text to markdown, detecting:
|
||
//! - Headers (by font size)
|
||
//! - Lists (bullet points, numbered lists)
|
||
//! - Code blocks (monospace fonts, indentation)
|
||
//! - Paragraphs
|
||
|
||
pub(crate) mod analysis;
|
||
mod classify;
|
||
mod convert;
|
||
mod heading;
|
||
mod postprocess;
|
||
mod preprocess;
|
||
|
||
pub use convert::to_markdown_from_lines;
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use crate::types::{PdfLine, PdfRect, TextItem};
|
||
|
||
use analysis::calculate_font_stats_from_items;
|
||
use classify::{format_list_item, is_caption_line, is_code_like, is_list_item};
|
||
use convert::{
|
||
merge_continuation_tables, to_markdown_from_lines_with_tables_and_images, ChartProseOrder,
|
||
PositionedMarkdown,
|
||
};
|
||
|
||
const CHART_REGION_PAD: f32 = 20.0;
|
||
const CHART_SEPARATOR_PAD: f32 = 8.0;
|
||
|
||
fn is_chart_adjacent_label(item: &TextItem, region: (f32, f32, f32, f32)) -> bool {
|
||
let text = item.text.trim();
|
||
let is_bare_bullet = matches!(text, "•" | "●" | "○" | "◦" | "-" | "*");
|
||
if text.is_empty() || is_list_item(text) || is_bare_bullet {
|
||
return false;
|
||
}
|
||
|
||
let (x0, y0, x1, y1) = region;
|
||
let (left, right) = (x0.min(x1), x0.max(x1));
|
||
let (bottom, top) = (y0.min(y1), y0.max(y1));
|
||
let item_left = item.x.min(item.x + item.width);
|
||
let item_right = item.x.max(item.x + item.width);
|
||
let item_width = (item_right - item_left).max(1.0);
|
||
let chart_width = (right - left).max(1.0);
|
||
let horizontal_overlap = (item_right.min(right) - item_left.max(left)).max(0.0);
|
||
let mostly_inside_chart_width = horizontal_overlap >= item_width * 0.8;
|
||
let vertical_gap = if item.y < bottom {
|
||
bottom - item.y
|
||
} else if item.y > top {
|
||
item.y - top
|
||
} else {
|
||
0.0
|
||
};
|
||
let is_caption = is_caption_line(text);
|
||
let em = item.height.max(item.font_size).max(1.0);
|
||
let compact_label = item_width <= em * 18.5;
|
||
let category_band = (em * 1.85).clamp(6.0, CHART_REGION_PAD);
|
||
let close_to_chart_edge = if is_caption {
|
||
vertical_gap <= CHART_REGION_PAD
|
||
} else {
|
||
vertical_gap <= category_band
|
||
};
|
||
let category_sized = item_width <= chart_width * 0.75;
|
||
|
||
vertical_gap <= CHART_REGION_PAD
|
||
&& (compact_label
|
||
|| is_caption
|
||
|| (mostly_inside_chart_width && close_to_chart_edge && category_sized))
|
||
}
|
||
|
||
pub(crate) fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool {
|
||
regions.iter().any(|&(x0, y0, x1, y1)| {
|
||
let cx = item.x + item.width / 2.0;
|
||
let within_padded_x = cx >= x0 - CHART_REGION_PAD && cx <= x1 + CHART_REGION_PAD;
|
||
let within_core_y = item.y >= y0 && item.y <= y1;
|
||
let within_padded_y = item.y >= y0 - CHART_REGION_PAD
|
||
&& item.y <= y1 + CHART_REGION_PAD
|
||
&& is_chart_adjacent_label(item, (x0, y0, x1, y1));
|
||
within_padded_x && (within_core_y || within_padded_y)
|
||
})
|
||
}
|
||
|
||
fn items_outside_chart_regions(
|
||
items: &[TextItem],
|
||
regions: &[(f32, f32, f32, f32)],
|
||
) -> Vec<TextItem> {
|
||
items
|
||
.iter()
|
||
.filter(|item| !item_is_in_chart_region(item, regions))
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn merge_chart_regions(
|
||
regions: impl IntoIterator<Item = (f32, f32, f32, f32)>,
|
||
) -> Vec<(f32, f32, f32, f32)> {
|
||
const MERGE_TOLERANCE: f32 = 3.0;
|
||
|
||
let mut merged: Vec<(f32, f32, f32, f32)> = Vec::new();
|
||
for (x0, y0, x1, y1) in regions {
|
||
let mut current = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
let mut index = 0;
|
||
while index < merged.len() {
|
||
let candidate = merged[index];
|
||
let overlaps = current.2 + MERGE_TOLERANCE >= candidate.0
|
||
&& candidate.2 + MERGE_TOLERANCE >= current.0
|
||
&& current.3 + MERGE_TOLERANCE >= candidate.1
|
||
&& candidate.3 + MERGE_TOLERANCE >= current.1;
|
||
if overlaps {
|
||
current = (
|
||
current.0.min(candidate.0),
|
||
current.1.min(candidate.1),
|
||
current.2.max(candidate.2),
|
||
current.3.max(candidate.3),
|
||
);
|
||
merged.swap_remove(index);
|
||
} else {
|
||
index += 1;
|
||
}
|
||
}
|
||
merged.push(current);
|
||
}
|
||
merged
|
||
}
|
||
|
||
pub(crate) type PageChartRegions = HashMap<u32, Vec<(f32, f32, f32, f32)>>;
|
||
|
||
/// Compute the chart masks used by both layout analysis and Markdown output.
|
||
///
|
||
/// Keeping the rect-backed and dense-line heuristics behind one entry point
|
||
/// ensures metadata and extraction cannot drift when either detector changes.
|
||
pub(crate) fn chart_regions_by_page(
|
||
items: &[TextItem],
|
||
rects: &[PdfRect],
|
||
lines: &[PdfLine],
|
||
) -> PageChartRegions {
|
||
let mut page_items: HashMap<u32, Vec<TextItem>> = HashMap::new();
|
||
for item in items.iter().filter(|item| {
|
||
matches!(
|
||
&item.item_type,
|
||
crate::types::ItemType::Text | crate::types::ItemType::FormField
|
||
)
|
||
}) {
|
||
page_items.entry(item.page).or_default().push(item.clone());
|
||
}
|
||
|
||
page_items
|
||
.into_iter()
|
||
.filter_map(|(page, items)| {
|
||
let rect_regions = crate::tables::detect_chart_regions(&items, rects, page);
|
||
let line_regions = crate::tables::detect_dense_line_chart_regions(lines, rects, page)
|
||
.into_iter()
|
||
.filter(|®ion| chart_region_separates_prose_columns(&items, region));
|
||
let regions = merge_chart_regions(rect_regions.into_iter().chain(line_regions));
|
||
(!regions.is_empty()).then_some((page, regions))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Detect side-by-side table layout by finding a significant X-position gap.
|
||
///
|
||
/// Returns X-band boundaries `[(x_min, split_x), (split_x, x_max)]` when a
|
||
/// clear vertical gap separates two groups of items, or an empty vec if the
|
||
/// page has a single-region layout.
|
||
///
|
||
/// Candidate gaps must be ≥30pt and in the middle 60% of the page's X range.
|
||
/// Items are counted by center position for accurate balance (each side ≥20%).
|
||
/// The candidate with the fewest bounding-box crossings is chosen (must be
|
||
/// under 5% of total items). To reject single wide tables with multiple
|
||
/// column gaps, only pages with one balanced-candidate cluster (within 50pt)
|
||
/// are accepted.
|
||
pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
||
if items.len() < 40 {
|
||
return vec![];
|
||
}
|
||
|
||
// Sort items by left edge
|
||
let mut xs: Vec<f32> = items.iter().map(|i| i.x).collect();
|
||
xs.sort_by(|a, b| a.total_cmp(b));
|
||
|
||
// Find all candidate gaps: ≥30pt, in the middle 60% of the X range,
|
||
// with ≥20 items on each side.
|
||
let x_min = xs[0];
|
||
let x_max = *xs.last().unwrap();
|
||
let x_range = x_max - x_min;
|
||
let center_lo = x_min + x_range * 0.2;
|
||
let center_hi = x_min + x_range * 0.8;
|
||
let mut candidates: Vec<f32> = Vec::new();
|
||
for i in 1..xs.len() {
|
||
let gap = xs[i] - xs[i - 1];
|
||
let split_x = (xs[i - 1] + xs[i]) / 2.0;
|
||
if gap >= 30.0
|
||
&& i >= 20
|
||
&& (xs.len() - i) >= 20
|
||
&& split_x >= center_lo
|
||
&& split_x <= center_hi
|
||
{
|
||
candidates.push(split_x);
|
||
}
|
||
}
|
||
|
||
if candidates.is_empty() {
|
||
return vec![];
|
||
}
|
||
|
||
// Pick the candidate with the fewest bounding-box crossings,
|
||
// but only consider balanced splits (each side ≥ 20% of total items
|
||
// by center position, which is more accurate than left-edge counting).
|
||
let min_side = items.len() / 5;
|
||
let mut best_split = 0.0f32;
|
||
let mut best_crossing = usize::MAX;
|
||
for &split_x in &candidates {
|
||
// Count items by center position for accurate balance check
|
||
let left_count = items
|
||
.iter()
|
||
.filter(|i| i.x + i.width / 2.0 < split_x)
|
||
.count();
|
||
let right_count = items.len() - left_count;
|
||
if left_count.min(right_count) < min_side {
|
||
continue;
|
||
}
|
||
let crossing = items
|
||
.iter()
|
||
.filter(|item| item.x < split_x && (item.x + item.width) > split_x)
|
||
.count();
|
||
if crossing < best_crossing {
|
||
best_crossing = crossing;
|
||
best_split = split_x;
|
||
}
|
||
}
|
||
|
||
if best_crossing == usize::MAX {
|
||
return vec![];
|
||
}
|
||
|
||
// Crossing items must be < 5% of total (allows spanning headers/labels)
|
||
let max_crossing = (items.len() / 20).max(2);
|
||
if best_crossing > max_crossing {
|
||
return vec![];
|
||
}
|
||
|
||
// Multiple balanced split candidates that are far apart indicate a
|
||
// multi-column single table. Adjacent candidates (within 20pt) are
|
||
// treated as the same split point. Side-by-side tables have exactly
|
||
// one cluster of candidates near the inter-table gap.
|
||
let mut balanced_positions: Vec<f32> = candidates
|
||
.iter()
|
||
.filter(|&&sx| {
|
||
let lc = items.iter().filter(|i| i.x + i.width / 2.0 < sx).count();
|
||
let rc = items.len() - lc;
|
||
lc.min(rc) >= min_side
|
||
})
|
||
.copied()
|
||
.collect();
|
||
balanced_positions.sort_by(|a, b| a.total_cmp(b));
|
||
balanced_positions.dedup_by(|a, b| (*a - *b).abs() < 50.0);
|
||
if balanced_positions.len() > 1 {
|
||
return vec![];
|
||
}
|
||
|
||
// Don't split when the left side is text labels and the right side is numeric
|
||
// data at matching Y positions — this is a single table (labels + numbers),
|
||
// not two independent side-by-side regions.
|
||
// Requires ALL THREE: left side is mostly non-numeric, right side is mostly
|
||
// numeric, AND high Y-correlation between the two sides.
|
||
let is_numeric_item = |item: &&&TextItem| -> bool {
|
||
let text = item.text.trim();
|
||
if text.is_empty() {
|
||
return false;
|
||
}
|
||
let data_chars = text
|
||
.chars()
|
||
.filter(|c| c.is_ascii_digit() || ",.-+%€$£¥()".contains(*c))
|
||
.count();
|
||
data_chars as f32 / text.chars().count() as f32 >= 0.6
|
||
};
|
||
|
||
let left_items: Vec<&TextItem> = items
|
||
.iter()
|
||
.filter(|i| i.x + i.width / 2.0 < best_split)
|
||
.collect();
|
||
let right_items: Vec<&TextItem> = items
|
||
.iter()
|
||
.filter(|i| i.x + i.width / 2.0 >= best_split)
|
||
.collect();
|
||
|
||
if !left_items.is_empty() && !right_items.is_empty() {
|
||
let left_numeric_ratio =
|
||
left_items.iter().filter(is_numeric_item).count() as f32 / left_items.len() as f32;
|
||
let right_numeric_ratio =
|
||
right_items.iter().filter(is_numeric_item).count() as f32 / right_items.len() as f32;
|
||
|
||
// Left side is mostly text (< 30% numeric) AND right side is mostly numbers (≥ 70%)
|
||
if left_numeric_ratio < 0.30 && right_numeric_ratio >= 0.70 {
|
||
let y_tol = 5.0;
|
||
let y_matches = right_items
|
||
.iter()
|
||
.filter(|ri| left_items.iter().any(|li| (li.y - ri.y).abs() < y_tol))
|
||
.count();
|
||
if y_matches as f32 / right_items.len() as f32 >= 0.5 {
|
||
return vec![];
|
||
}
|
||
}
|
||
}
|
||
|
||
vec![(x_min, best_split), (best_split, x_max)]
|
||
}
|
||
|
||
/// Detect two short prose columns on a chart page from repeated left anchors.
|
||
///
|
||
/// Chart masking can leave fewer than 20 lines per column, while justified text
|
||
/// can reduce the physical gutter below the projection detector's 8pt minimum.
|
||
/// Repeated prose left edges remain reliable in that case. The caller scopes
|
||
/// this signal to pages with a confirmed chart region.
|
||
fn chart_page_prose_column_split(items: &[TextItem]) -> Option<f32> {
|
||
const X_TOLERANCE: f32 = 12.0;
|
||
const MIN_LINES_PER_COLUMN: usize = 6;
|
||
const MIN_ANCHOR_SEPARATION: f32 = 120.0;
|
||
const MIN_VERTICAL_SPAN: f32 = 60.0;
|
||
|
||
let mut prose: Vec<&TextItem> = items
|
||
.iter()
|
||
.filter(|item| {
|
||
let words = item.text.split_whitespace().count();
|
||
let chars = item.text.chars().count().max(1);
|
||
let alphabetic = item.text.chars().filter(|c| c.is_alphabetic()).count();
|
||
words >= 4 && item.width >= 80.0 && alphabetic * 2 >= chars
|
||
})
|
||
.collect();
|
||
if prose.len() < MIN_LINES_PER_COLUMN * 2 {
|
||
return None;
|
||
}
|
||
prose.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||
|
||
let mut clusters: Vec<(f32, Vec<&TextItem>)> = Vec::new();
|
||
for item in prose {
|
||
if let Some((anchor, members)) = clusters
|
||
.iter_mut()
|
||
.find(|(anchor, _)| (item.x - *anchor).abs() <= X_TOLERANCE)
|
||
{
|
||
members.push(item);
|
||
*anchor = members.iter().map(|member| member.x).sum::<f32>() / members.len() as f32;
|
||
} else {
|
||
clusters.push((item.x, vec![item]));
|
||
}
|
||
}
|
||
|
||
let mut dominant: Vec<(f32, Vec<&TextItem>)> = clusters
|
||
.into_iter()
|
||
.filter(|(_, members)| members.len() >= MIN_LINES_PER_COLUMN)
|
||
.collect();
|
||
if dominant.len() != 2 {
|
||
return None;
|
||
}
|
||
dominant.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
if dominant[1].0 - dominant[0].0 < MIN_ANCHOR_SEPARATION {
|
||
return None;
|
||
}
|
||
|
||
let vertical_range = |members: &[&TextItem]| {
|
||
let y_min = members
|
||
.iter()
|
||
.map(|item| item.y)
|
||
.fold(f32::INFINITY, f32::min);
|
||
let y_max = members
|
||
.iter()
|
||
.map(|item| item.y)
|
||
.fold(f32::NEG_INFINITY, f32::max);
|
||
(y_min, y_max)
|
||
};
|
||
let left_y = vertical_range(&dominant[0].1);
|
||
let right_y = vertical_range(&dominant[1].1);
|
||
if left_y.1 - left_y.0 < MIN_VERTICAL_SPAN || right_y.1 - right_y.0 < MIN_VERTICAL_SPAN {
|
||
return None;
|
||
}
|
||
let overlap = (left_y.1.min(right_y.1) - left_y.0.max(right_y.0)).max(0.0);
|
||
let shorter_span = (left_y.1 - left_y.0).min(right_y.1 - right_y.0);
|
||
if overlap < shorter_span * 0.4 {
|
||
return None;
|
||
}
|
||
|
||
Some((dominant[0].0 + dominant[1].0) / 2.0)
|
||
}
|
||
|
||
/// True when a chart crosses the inferred prose gutter by enough to act as a
|
||
/// page-wide separator. A chart confined to one column must stay in that
|
||
/// column's local reading order instead of reordering the entire page.
|
||
fn chart_spans_prose_split(region: (f32, f32, f32, f32), split_x: f32) -> bool {
|
||
const MIN_CHART_WIDTH_PER_SIDE: f32 = 40.0;
|
||
|
||
let (x0, _, x1, _) = region;
|
||
let left = x0.min(x1);
|
||
let right = x0.max(x1);
|
||
split_x - left >= MIN_CHART_WIDTH_PER_SIDE && right - split_x >= MIN_CHART_WIDTH_PER_SIDE
|
||
}
|
||
|
||
pub(crate) fn chart_region_separates_prose_columns(
|
||
items: &[TextItem],
|
||
region: (f32, f32, f32, f32),
|
||
) -> bool {
|
||
let outside = items_outside_chart_regions(items, &[region]);
|
||
chart_page_prose_column_split(&outside)
|
||
.is_some_and(|split_x| chart_spans_prose_split(region, split_x))
|
||
}
|
||
|
||
/// True when adjacent physical rows form an unterminated, lowercase prose
|
||
/// continuation in the same projected column.
|
||
fn is_cross_row_prose_continuation(previous: &str, current: &str) -> bool {
|
||
let previous = previous.trim();
|
||
let current = current.trim();
|
||
if previous.is_empty() || current.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
let previous_without_closers = previous.trim_end_matches(['"', '\'', '”', ')', ']']);
|
||
let previous_is_open = previous_without_closers
|
||
.chars()
|
||
.next_back()
|
||
.is_some_and(|ch| !matches!(ch, '.' | '!' | '?' | ':' | ';'));
|
||
let current_starts_as_continuation = current
|
||
.chars()
|
||
.find(|ch| ch.is_alphabetic())
|
||
.is_some_and(|ch| ch.is_lowercase());
|
||
|
||
previous_is_open && current_starts_as_continuation
|
||
}
|
||
|
||
/// Section-numbered headings embedded in a candidate are strong evidence that
|
||
/// a heuristic grid has captured page prose rather than a real table.
|
||
fn looks_like_numbered_section_heading(text: &str) -> bool {
|
||
let Some((prefix, title)) = text.trim().split_once(char::is_whitespace) else {
|
||
return false;
|
||
};
|
||
let prefix = prefix.trim_end_matches('.');
|
||
let mut group_count = 0;
|
||
for group in prefix.split('.') {
|
||
if group.is_empty() || group.len() > 3 || !group.chars().all(|ch| ch.is_ascii_digit()) {
|
||
return false;
|
||
}
|
||
group_count += 1;
|
||
}
|
||
let title = title.trim();
|
||
(1..=4).contains(&group_count)
|
||
&& title.split_whitespace().count() >= 3
|
||
&& title
|
||
.chars()
|
||
.find(|ch| ch.is_alphabetic())
|
||
.is_some_and(|ch| ch.is_uppercase())
|
||
}
|
||
|
||
fn merged_retry_skips_body_font(detected_columns: bool, has_chart_regions: bool) -> bool {
|
||
detected_columns && !has_chart_regions
|
||
}
|
||
|
||
/// Identity of a piece of page furniture: the same trimmed text drawn at the
|
||
/// same position (quantized to 0.5pt) — page numbers excluded by construction
|
||
/// because their text differs per page.
|
||
type FurnitureKey = (String, i32, i32);
|
||
|
||
fn furniture_key(item: &TextItem) -> FurnitureKey {
|
||
(
|
||
item.text.trim().to_string(),
|
||
(item.x * 2.0).round() as i32,
|
||
(item.y * 2.0).round() as i32,
|
||
)
|
||
}
|
||
|
||
/// Minimum distinct pages an identical (text, position) must appear on before
|
||
/// it counts as a running header/footer rather than coincidence.
|
||
const RUNNING_FURNITURE_MIN_PAGES: usize = 3;
|
||
|
||
/// Fraction of each page's vertical content extent, at the top and at the
|
||
/// bottom, where running furniture may live. Repetition alone is not enough:
|
||
/// a form template repeated per record carries identical labels at identical
|
||
/// mid-page coordinates on every page, and those are real table cells. What
|
||
/// makes a header/footer is repetition *at the page edge*.
|
||
const RUNNING_FURNITURE_BAND: f32 = 0.2;
|
||
|
||
/// Collect the keys of items that repeat verbatim at the same position on at
|
||
/// least [`RUNNING_FURNITURE_MIN_PAGES`] distinct pages, restricted to the
|
||
/// top/bottom [`RUNNING_FURNITURE_BAND`] of each page's content extent —
|
||
/// running headers and footers. Single- and two-page documents produce an
|
||
/// empty set.
|
||
fn running_furniture_keys(items: &[TextItem]) -> HashSet<FurnitureKey> {
|
||
// Vertical content extent per page, so the edge bands adapt to the
|
||
// document's real margins instead of assuming a media box.
|
||
let mut page_extent: HashMap<u32, (f32, f32)> = HashMap::new();
|
||
for item in items {
|
||
if item.text.trim().is_empty() {
|
||
continue;
|
||
}
|
||
let entry = page_extent.entry(item.page).or_insert((item.y, item.y));
|
||
entry.0 = entry.0.min(item.y);
|
||
entry.1 = entry.1.max(item.y);
|
||
}
|
||
|
||
let mut pages_by_key: HashMap<FurnitureKey, HashSet<u32>> = HashMap::new();
|
||
for item in items {
|
||
if item.text.trim().is_empty() {
|
||
continue;
|
||
}
|
||
let Some(&(min_y, max_y)) = page_extent.get(&item.page) else {
|
||
continue;
|
||
};
|
||
// A page whose text has no vertical span gives no evidence of where
|
||
// its edges are — without this guard, a zero band would classify its
|
||
// every item as edge furniture.
|
||
let extent = max_y - min_y;
|
||
if extent <= 0.0 {
|
||
continue;
|
||
}
|
||
let band = extent * RUNNING_FURNITURE_BAND;
|
||
if item.y > min_y + band && item.y < max_y - band {
|
||
continue; // mid-page: never furniture, however often it repeats
|
||
}
|
||
pages_by_key
|
||
.entry(furniture_key(item))
|
||
.or_default()
|
||
.insert(item.page);
|
||
}
|
||
pages_by_key
|
||
.into_iter()
|
||
.filter(|(_, pages)| pages.len() >= RUNNING_FURNITURE_MIN_PAGES)
|
||
.map(|(key, _)| key)
|
||
.collect()
|
||
}
|
||
|
||
/// Reject a heuristic table whose items are almost entirely running
|
||
/// headers/footers. A wrapped document title repeated at the bottom of every
|
||
/// page aligns well enough to read as a grid, but it is page furniture, not
|
||
/// data — vetoing the table lets the text flow as prose instead. Real tables
|
||
/// carry per-page content, so even a repeated *header row* stays under the
|
||
/// threshold once its body rows differ.
|
||
fn is_running_furniture_table(
|
||
detection_items: &[TextItem],
|
||
table: &crate::tables::Table,
|
||
running: &HashSet<FurnitureKey>,
|
||
) -> bool {
|
||
if running.is_empty() {
|
||
return false;
|
||
}
|
||
let mut total = 0usize;
|
||
let mut furniture = 0usize;
|
||
for &idx in &table.item_indices {
|
||
let Some(item) = detection_items.get(idx) else {
|
||
continue;
|
||
};
|
||
if item.text.trim().is_empty() {
|
||
continue;
|
||
}
|
||
total += 1;
|
||
if running.contains(&furniture_key(item)) {
|
||
furniture += 1;
|
||
}
|
||
}
|
||
total > 0 && (furniture as f32) >= (total as f32) * 0.8
|
||
}
|
||
|
||
/// Reject a heuristic table only when its cells are overwhelmingly parallel
|
||
/// prose fragments. This is deliberately narrower than disabling body-font
|
||
/// detection for the whole page: numeric, compact, headed, and otherwise
|
||
/// table-shaped candidates remain eligible on chart pages.
|
||
fn is_parallel_prose_table(table: &crate::tables::Table) -> bool {
|
||
if table.kind != crate::tables::TableKind::Data
|
||
|| !(2..=3).contains(&table.columns.len())
|
||
|| table.rows.len() < 3
|
||
{
|
||
return false;
|
||
}
|
||
|
||
let mut non_empty = 0;
|
||
let mut long_prose = 0;
|
||
let mut rows_with_parallel_prose = 0;
|
||
let mut occupied_rows = 0;
|
||
let has_numbered_section_heading = table
|
||
.cells
|
||
.iter()
|
||
.flatten()
|
||
.any(|cell| looks_like_numbered_section_heading(cell));
|
||
let has_compact_header = table
|
||
.cells
|
||
.iter()
|
||
.find(|row| row.iter().any(|cell| !cell.trim().is_empty()))
|
||
.is_some_and(|row| {
|
||
let filled: Vec<&String> = row.iter().filter(|cell| !cell.trim().is_empty()).collect();
|
||
filled.len() >= 2
|
||
&& filled.iter().all(|cell| {
|
||
cell.split_whitespace().count() <= 4 && cell.trim().chars().count() <= 28
|
||
})
|
||
});
|
||
|
||
for row in &table.cells {
|
||
let mut row_long_prose = 0;
|
||
let mut row_non_empty = 0;
|
||
for cell in row {
|
||
let text = cell.trim();
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
non_empty += 1;
|
||
row_non_empty += 1;
|
||
let chars = text.chars().filter(|ch| !ch.is_whitespace()).count();
|
||
let alphabetic = text.chars().filter(|ch| ch.is_alphabetic()).count();
|
||
let words = text.split_whitespace().count();
|
||
if chars >= 28 && words >= 5 && alphabetic * 5 >= chars * 3 {
|
||
long_prose += 1;
|
||
row_long_prose += 1;
|
||
}
|
||
}
|
||
if row_long_prose >= 2 {
|
||
rows_with_parallel_prose += 1;
|
||
}
|
||
if row_non_empty > 0 {
|
||
occupied_rows += 1;
|
||
}
|
||
}
|
||
|
||
// A lowercase cell is not continuation evidence by itself: legitimate
|
||
// headerless tables often use sentence fragments as row values. Require a
|
||
// direct physical-row transition from an unterminated cell in the same
|
||
// column. This is the shape produced when independent prose columns are
|
||
// accidentally projected onto one table grid.
|
||
let mut continuation_fragments = 0;
|
||
let mut continuation_columns = vec![false; table.columns.len()];
|
||
for rows in table.cells.windows(2) {
|
||
for (column, has_continuation) in continuation_columns.iter_mut().enumerate() {
|
||
let previous = rows[0].get(column).map(String::as_str).unwrap_or("");
|
||
let current = rows[1].get(column).map(String::as_str).unwrap_or("");
|
||
if is_cross_row_prose_continuation(previous, current) {
|
||
continuation_fragments += 1;
|
||
*has_continuation = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
let is_parallel = !has_compact_header
|
||
&& non_empty >= 5
|
||
// Independent prose columns have asynchronous line/paragraph breaks;
|
||
// a fully populated grid is positive evidence for a real descriptive
|
||
// table even when every value is a lowercase sentence fragment.
|
||
&& non_empty < table.cells.len() * table.columns.len()
|
||
&& long_prose >= 4
|
||
&& long_prose * 5 >= non_empty * 3
|
||
// Row-spanning blanks are common in real headerless description
|
||
// tables. Require long text in parallel on at least half of occupied
|
||
// rows, unless a section heading was swallowed into the grid: that is
|
||
// direct evidence that this candidate is page prose.
|
||
&& ((rows_with_parallel_prose >= 2
|
||
&& rows_with_parallel_prose * 2 >= occupied_rows)
|
||
|| (rows_with_parallel_prose >= 1 && has_numbered_section_heading))
|
||
&& continuation_fragments >= 3
|
||
&& continuation_columns.iter().filter(|&&value| value).count() >= 2;
|
||
log::debug!(
|
||
"chart table hypothesis: {}x{}, non_empty={}, long_prose={}, parallel_rows={}/{}, section_heading={}, continuation_fragments={}, continuation_columns={}, reject={}",
|
||
table.rows.len(),
|
||
table.columns.len(),
|
||
non_empty,
|
||
long_prose,
|
||
rows_with_parallel_prose,
|
||
occupied_rows,
|
||
has_numbered_section_heading,
|
||
continuation_fragments,
|
||
continuation_columns.iter().filter(|&&value| value).count(),
|
||
is_parallel
|
||
);
|
||
is_parallel
|
||
}
|
||
|
||
fn positioned_table(
|
||
table: &crate::tables::Table,
|
||
chart_order: Option<ChartProseOrder>,
|
||
) -> PositionedMarkdown {
|
||
PositionedMarkdown::new(
|
||
table.rows.first().copied().unwrap_or(0.0),
|
||
table.columns.first().copied().unwrap_or(0.0),
|
||
crate::tables::table_to_markdown(table),
|
||
chart_order,
|
||
)
|
||
}
|
||
|
||
/// Derive a side-by-side split from rect hint regions.
|
||
///
|
||
/// When `split_side_by_side` doesn't detect a gap (e.g. the text gap is too
|
||
/// small), hint regions from large rect clusters can still reveal a left/right
|
||
/// zone layout (calendar months, form sections). This function checks if hint
|
||
/// regions pair up at the same Y bands and returns `[(x_min, split), (split,
|
||
/// x_max)]` if a consistent split exists.
|
||
/// True when a table-shaped rect cluster (≥6 rects) ends at an interior band
|
||
/// boundary and its rows visibly continue on the far side: cell-like text
|
||
/// across the boundary is y-aligned with most cluster rows, and nearly all
|
||
/// far-side text in the cluster's y-range participates in that alignment.
|
||
/// Tables often rule only their leading columns, so the text gap before the
|
||
/// borderless columns masquerades as a page-layout gutter — a real second
|
||
/// layout column would instead be dense prose that doesn't track table rows.
|
||
fn rect_cluster_spans_band_boundary(
|
||
items: &[TextItem],
|
||
rects: &[PdfRect],
|
||
page: u32,
|
||
bands: &[(f32, f32)],
|
||
) -> bool {
|
||
if bands.len() < 2 {
|
||
return false;
|
||
}
|
||
// Normalize: raw PDF rects can carry negative extents.
|
||
let page_rects: Vec<(f32, f32, f32, f32)> = rects
|
||
.iter()
|
||
.filter(|r| r.page == page)
|
||
.map(|r| {
|
||
let (x, w) = if r.width < 0.0 {
|
||
(r.x + r.width, -r.width)
|
||
} else {
|
||
(r.x, r.width)
|
||
};
|
||
let (y, h) = if r.height < 0.0 {
|
||
(r.y + r.height, -r.height)
|
||
} else {
|
||
(r.y, r.height)
|
||
};
|
||
(x, y, w, h)
|
||
})
|
||
.collect();
|
||
if page_rects.len() < 6 {
|
||
return false;
|
||
}
|
||
let clusters = crate::tables::detect_rects::cluster_rects(&page_rects, 3.0, 6);
|
||
let boundaries: Vec<f32> = bands[..bands.len() - 1].iter().map(|&(_, hi)| hi).collect();
|
||
|
||
boundaries.iter().any(|&b| {
|
||
// Y-ranges of clusters that individually indicate the split cuts a
|
||
// table: either ruled on both sides of the boundary, or ending at
|
||
// the boundary with cell-like text row-aligned beyond it.
|
||
let mut table_y_ranges: Vec<(f32, f32)> = Vec::new();
|
||
for cluster in &clusters {
|
||
let bbox = cluster.iter().fold(
|
||
(
|
||
f32::INFINITY,
|
||
f32::INFINITY,
|
||
f32::NEG_INFINITY,
|
||
f32::NEG_INFINITY,
|
||
),
|
||
|(x0, y0, x1, y1), &i| {
|
||
let (x, y, w, h) = page_rects[i];
|
||
(x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h))
|
||
},
|
||
);
|
||
let spans = bbox.0 < b - 20.0 && bbox.2 > b + 20.0;
|
||
let ends_at = bbox.2 >= b - 60.0 && bbox.2 <= b + 10.0 && bbox.0 <= b;
|
||
if !spans && !ends_at {
|
||
continue;
|
||
}
|
||
// Distinct row baselines of items inside the cluster bbox.
|
||
let mut row_ys: Vec<f32> = Vec::new();
|
||
for it in items {
|
||
let cx = it.x + it.width / 2.0;
|
||
if it.page == page
|
||
&& cx > bbox.0
|
||
&& cx < bbox.2
|
||
&& it.y >= bbox.1 - 2.0
|
||
&& it.y <= bbox.3 + 2.0
|
||
&& !row_ys.iter().any(|&y| (y - it.y).abs() <= 2.0)
|
||
{
|
||
row_ys.push(it.y);
|
||
}
|
||
}
|
||
if row_ys.len() < 2 {
|
||
continue;
|
||
}
|
||
// Cell-like far-side items row-aligned with the cluster.
|
||
let cell_like = |it: &&TextItem| it.width <= 150.0;
|
||
let far_aligned_rows = row_ys
|
||
.iter()
|
||
.filter(|&&y| {
|
||
items.iter().any(|it| {
|
||
it.page == page
|
||
&& it.x + it.width / 2.0 > b
|
||
&& cell_like(&it)
|
||
&& (it.y - y).abs() <= 2.0
|
||
})
|
||
})
|
||
.count();
|
||
if far_aligned_rows >= 2 && far_aligned_rows * 2 >= row_ys.len() {
|
||
table_y_ranges.push((bbox.1, bbox.3));
|
||
}
|
||
}
|
||
if table_y_ranges.is_empty() {
|
||
return false;
|
||
}
|
||
// The split is only wrong if the table rows account for most of the
|
||
// far side. A figure legitimately spanning two text columns leaves
|
||
// the majority of far-side text (column prose) outside its y-range.
|
||
let far: Vec<&TextItem> = items
|
||
.iter()
|
||
.filter(|it| it.page == page && it.x + it.width / 2.0 > b)
|
||
.collect();
|
||
if far.is_empty() {
|
||
return false;
|
||
}
|
||
let inside = far
|
||
.iter()
|
||
.filter(|it| {
|
||
table_y_ranges
|
||
.iter()
|
||
.any(|&(lo, hi)| it.y >= lo - 2.0 && it.y <= hi + 2.0)
|
||
})
|
||
.count();
|
||
inside * 10 >= far.len() * 6
|
||
})
|
||
}
|
||
|
||
fn split_from_hint_regions(items: &[TextItem], rects: &[PdfRect], page: u32) -> Vec<(f32, f32)> {
|
||
use crate::tables::{cluster_rects, RectHintRegion};
|
||
|
||
// Quick hint region computation (same logic as detect_tables_from_rects
|
||
// but without table detection).
|
||
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
|
||
for r in rects {
|
||
if r.page != page {
|
||
continue;
|
||
}
|
||
let (mut x, mut y, mut w, mut h) = (r.x, r.y, r.width, r.height);
|
||
if w < 0.0 {
|
||
x += w;
|
||
w = -w;
|
||
}
|
||
if h < 0.0 {
|
||
y += h;
|
||
h = -h;
|
||
}
|
||
if w < 5.0 || h < 5.0 {
|
||
continue;
|
||
}
|
||
page_rects.push((x, y, w, h));
|
||
}
|
||
if page_rects.len() < 60 {
|
||
return vec![];
|
||
}
|
||
|
||
// Width outlier filter (same as detect_tables_from_rects)
|
||
let mut widths: Vec<f32> = page_rects.iter().map(|&(_, _, w, _)| w).collect();
|
||
widths.sort_by(|a, b| a.total_cmp(b));
|
||
let median_width = widths[widths.len() / 2];
|
||
page_rects.retain(|&(_, _, w, _)| w <= median_width * 10.0);
|
||
|
||
let clusters = cluster_rects(&page_rects, 3.0, 6);
|
||
if clusters.len() < 4 {
|
||
return vec![];
|
||
}
|
||
|
||
// Build hint regions from large clusters
|
||
let mut hints: Vec<RectHintRegion> = Vec::new();
|
||
for cluster_indices in &clusters {
|
||
let group_rects: Vec<(f32, f32, f32, f32)> =
|
||
cluster_indices.iter().map(|&i| page_rects[i]).collect();
|
||
if group_rects.len() < 30 {
|
||
continue;
|
||
}
|
||
let x_left = group_rects.iter().map(|r| r.0).reduce(f32::min).unwrap();
|
||
let x_right = group_rects
|
||
.iter()
|
||
.map(|r| r.0 + r.2)
|
||
.reduce(f32::max)
|
||
.unwrap();
|
||
let y_bottom = group_rects.iter().map(|r| r.1).reduce(f32::min).unwrap();
|
||
let y_top = group_rects
|
||
.iter()
|
||
.map(|r| r.1 + r.3)
|
||
.reduce(f32::max)
|
||
.unwrap();
|
||
let w = x_right - x_left;
|
||
let h = y_top - y_bottom;
|
||
if (30.0..=400.0).contains(&w) && (10.0..=400.0).contains(&h) {
|
||
hints.push(RectHintRegion {
|
||
y_top,
|
||
y_bottom,
|
||
x_left,
|
||
x_right,
|
||
cluster_rects: Vec::new(),
|
||
});
|
||
}
|
||
}
|
||
if hints.len() < 4 {
|
||
return vec![];
|
||
}
|
||
|
||
// Check for left/right pairing: hints at the same Y band should split
|
||
// into distinct X groups. Count pairs where two hints share a Y band
|
||
// (>50% overlap) but occupy different X halves.
|
||
let page_x_mid = {
|
||
let x_min = items.iter().map(|i| i.x).reduce(f32::min).unwrap_or(0.0);
|
||
let x_max = items
|
||
.iter()
|
||
.map(|i| i.x + i.width)
|
||
.reduce(f32::max)
|
||
.unwrap_or(800.0);
|
||
(x_min + x_max) / 2.0
|
||
};
|
||
|
||
let mut pair_count = 0;
|
||
for (i, a) in hints.iter().enumerate() {
|
||
for b in hints.iter().skip(i + 1) {
|
||
let y_overlap = a.y_top.min(b.y_top) - a.y_bottom.max(b.y_bottom);
|
||
let y_min_span = (a.y_top - a.y_bottom).min(b.y_top - b.y_bottom);
|
||
if y_overlap > y_min_span * 0.5 {
|
||
let a_center = (a.x_left + a.x_right) / 2.0;
|
||
let b_center = (b.x_left + b.x_right) / 2.0;
|
||
if (a_center < page_x_mid) != (b_center < page_x_mid) {
|
||
pair_count += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Require at least 3 left/right pairs to confirm the layout
|
||
if pair_count < 3 {
|
||
return vec![];
|
||
}
|
||
|
||
// Find the split X: midpoint between the rightmost left-zone hint
|
||
// and the leftmost right-zone hint
|
||
let max_left_x = hints
|
||
.iter()
|
||
.filter(|h| (h.x_left + h.x_right) / 2.0 < page_x_mid)
|
||
.map(|h| h.x_right)
|
||
.reduce(f32::max);
|
||
let min_right_x = hints
|
||
.iter()
|
||
.filter(|h| (h.x_left + h.x_right) / 2.0 >= page_x_mid)
|
||
.map(|h| h.x_left)
|
||
.reduce(f32::min);
|
||
|
||
if let (Some(left_edge), Some(right_edge)) = (max_left_x, min_right_x) {
|
||
let split_x = (left_edge + right_edge) / 2.0;
|
||
let x_min = items.iter().map(|i| i.x).reduce(f32::min).unwrap_or(0.0);
|
||
let x_max = items
|
||
.iter()
|
||
.map(|i| i.x + i.width)
|
||
.reduce(f32::max)
|
||
.unwrap_or(800.0);
|
||
log::debug!(
|
||
"page {}: hint-derived side-by-side split at x={:.1}",
|
||
page,
|
||
split_x
|
||
);
|
||
vec![(x_min, split_x), (split_x, x_max)]
|
||
} else {
|
||
vec![]
|
||
}
|
||
}
|
||
|
||
/// Filter rects to those mostly contained within an X band.
|
||
///
|
||
/// Excludes rects that extend significantly beyond the band (e.g. page-wide
|
||
/// background stripes spanning both side-by-side tables). A rect must have
|
||
/// at least 70% of its width inside the band to be included.
|
||
pub(crate) fn filter_rects_to_band(
|
||
rects: &[PdfRect],
|
||
page: u32,
|
||
x_lo: f32,
|
||
x_hi: f32,
|
||
) -> Vec<PdfRect> {
|
||
let band_width = x_hi - x_lo;
|
||
rects
|
||
.iter()
|
||
.filter(|r| {
|
||
r.page == page && {
|
||
let rx_min = if r.width >= 0.0 { r.x } else { r.x + r.width };
|
||
let rx_max = if r.width >= 0.0 { r.x + r.width } else { r.x };
|
||
let rw = rx_max - rx_min;
|
||
// Overlap region
|
||
let overlap = rx_max.min(x_hi) - rx_min.max(x_lo);
|
||
if overlap <= 0.0 {
|
||
return false;
|
||
}
|
||
// Small rects (< 70% of band): require any overlap (cell borders, etc.)
|
||
// Large rects (≥ 70% of band): require ≥70% of rect inside band
|
||
if rw < band_width * 0.7 {
|
||
true
|
||
} else {
|
||
overlap >= rw * 0.7
|
||
}
|
||
}
|
||
})
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
/// A band of items/indices/rects/lines for side-by-side table detection.
|
||
type BandSpec = (Vec<TextItem>, Vec<usize>, Vec<PdfRect>, Vec<PdfLine>);
|
||
|
||
/// Filter PDF lines to those overlapping an X band.
|
||
pub(crate) fn filter_lines_to_band(
|
||
lines: &[PdfLine],
|
||
page: u32,
|
||
x_lo: f32,
|
||
x_hi: f32,
|
||
) -> Vec<PdfLine> {
|
||
lines
|
||
.iter()
|
||
.filter(|l| {
|
||
l.page == page && {
|
||
let lx_min = l.x1.min(l.x2);
|
||
let lx_max = l.x1.max(l.x2);
|
||
lx_max > x_lo && lx_min < x_hi
|
||
}
|
||
})
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
/// Output policy for Markdown post-processing.
|
||
///
|
||
/// [`MarkdownProfile::Fidelity`] preserves source characters wherever possible.
|
||
/// [`MarkdownProfile::Compact`] enables optional token-saving rewrites that may
|
||
/// be useful for agent context windows but are not byte-faithful to the PDF.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum MarkdownProfile {
|
||
/// Preserve source text fidelity. This is the default.
|
||
#[default]
|
||
Fidelity,
|
||
/// Prefer token-efficient output, including collapsing long dot leaders.
|
||
Compact,
|
||
}
|
||
|
||
/// Options for markdown conversion
|
||
#[derive(Debug, Clone)]
|
||
pub struct MarkdownOptions {
|
||
/// Source-fidelity versus token-efficient post-processing policy.
|
||
pub profile: MarkdownProfile,
|
||
/// Detect headers by font size
|
||
pub detect_headers: bool,
|
||
/// Detect list items
|
||
pub detect_lists: bool,
|
||
/// Detect code blocks
|
||
pub detect_code: bool,
|
||
/// Base font size for comparison
|
||
pub base_font_size: Option<f32>,
|
||
/// Remove standalone page numbers
|
||
pub remove_page_numbers: bool,
|
||
/// Convert URLs to markdown links
|
||
pub format_urls: bool,
|
||
/// Fix hyphenation (broken words across lines)
|
||
pub fix_hyphenation: bool,
|
||
/// Detect and format bold text from font names
|
||
pub detect_bold: bool,
|
||
/// Detect and format italic text from font names
|
||
pub detect_italic: bool,
|
||
/// Emit `<u>` runs for text with a geometrically-detected underline
|
||
pub detect_underline: bool,
|
||
/// Include image placeholders in output
|
||
pub include_images: bool,
|
||
/// Include extracted hyperlinks
|
||
pub include_links: bool,
|
||
/// Insert page break markers (<!-- Page N -->) between pages
|
||
pub include_page_numbers: bool,
|
||
/// Strip repeated headers/footers that appear on many pages
|
||
pub strip_headers_footers: bool,
|
||
}
|
||
|
||
impl Default for MarkdownOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
profile: MarkdownProfile::default(),
|
||
detect_headers: true,
|
||
detect_lists: true,
|
||
detect_code: true,
|
||
base_font_size: None,
|
||
remove_page_numbers: true,
|
||
format_urls: true,
|
||
fix_hyphenation: true,
|
||
detect_bold: true,
|
||
detect_italic: true,
|
||
detect_underline: true,
|
||
// `include_images: false` is intentional. The content-stream walker
|
||
// now emits `ItemType::Image` `TextItem`s for every Image XObject
|
||
// it encounters (see `extractor/content_stream.rs`). If we rendered
|
||
// those into markdown by default, every existing caller would
|
||
// suddenly see `` placeholders inserted
|
||
// throughout their output — a silent regression for anyone who
|
||
// upgrades. Image bboxes are still available via
|
||
// `extract_text_with_positions` for callers (e.g. layout-aware
|
||
// pipelines) that want to crop + caption figures themselves.
|
||
include_images: false,
|
||
include_links: true,
|
||
include_page_numbers: false,
|
||
strip_headers_footers: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Convert plain text to markdown (basic conversion)
|
||
pub fn to_markdown(text: &str, options: MarkdownOptions) -> String {
|
||
let mut output = String::new();
|
||
let mut in_list = false;
|
||
let mut in_code_block = false;
|
||
|
||
for line in text.lines() {
|
||
let trimmed = line.trim();
|
||
|
||
if trimmed.is_empty() {
|
||
if in_list {
|
||
in_list = false;
|
||
}
|
||
if in_code_block {
|
||
output.push_str("```\n");
|
||
in_code_block = false;
|
||
}
|
||
output.push('\n');
|
||
continue;
|
||
}
|
||
|
||
// Detect list items
|
||
if options.detect_lists && is_list_item(trimmed) {
|
||
let formatted = format_list_item(trimmed);
|
||
output.push_str(&formatted);
|
||
output.push('\n');
|
||
in_list = true;
|
||
continue;
|
||
}
|
||
|
||
// Detect code blocks (indented lines)
|
||
if options.detect_code && is_code_like(trimmed) {
|
||
if !in_code_block {
|
||
output.push_str("```\n");
|
||
in_code_block = true;
|
||
}
|
||
output.push_str(trimmed);
|
||
output.push('\n');
|
||
continue;
|
||
} else if in_code_block {
|
||
output.push_str("```\n");
|
||
in_code_block = false;
|
||
}
|
||
|
||
// Regular paragraph text
|
||
output.push_str(trimmed);
|
||
output.push('\n');
|
||
}
|
||
|
||
if in_code_block {
|
||
output.push_str("```\n");
|
||
}
|
||
|
||
output
|
||
}
|
||
|
||
/// Applies the document-wide repeated header/footer classifier to grouped lines.
|
||
pub(crate) fn strip_repeated_header_footer_lines(
|
||
lines: Vec<crate::types::TextLine>,
|
||
page_count: u32,
|
||
) -> Vec<crate::types::TextLine> {
|
||
preprocess::strip_repeated_lines(lines, page_count)
|
||
}
|
||
|
||
/// Convert positioned text items to markdown with structure detection
|
||
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
|
||
to_markdown_from_items_with_rects(items, options, &[])
|
||
}
|
||
|
||
/// Convert positioned text items to markdown, using rectangle data for table detection
|
||
pub fn to_markdown_from_items_with_rects(
|
||
items: Vec<TextItem>,
|
||
options: MarkdownOptions,
|
||
rects: &[crate::types::PdfRect],
|
||
) -> String {
|
||
let document_page_count = items.iter().map(|item| item.page).max().unwrap_or(0);
|
||
to_markdown_from_items_with_rects_and_page_count(items, options, rects, document_page_count)
|
||
}
|
||
|
||
/// Convert positioned text items to Markdown with an authoritative PDF page count.
|
||
///
|
||
/// Use this overload when the owning PDF is available so trailing blank or
|
||
/// unextracted pages are included in document-level header and folio coverage.
|
||
/// Item-only callers can continue using [`to_markdown_from_items_with_rects`],
|
||
/// which falls back to the highest observed item page.
|
||
pub fn to_markdown_from_items_with_rects_and_page_count(
|
||
items: Vec<TextItem>,
|
||
options: MarkdownOptions,
|
||
rects: &[crate::types::PdfRect],
|
||
document_page_count: u32,
|
||
) -> String {
|
||
to_markdown_from_items_with_rects_and_lines(
|
||
items,
|
||
options,
|
||
rects,
|
||
&[],
|
||
MarkdownDocumentContext {
|
||
page_thresholds: &HashMap::new(),
|
||
struct_roles: None,
|
||
struct_tables: &[],
|
||
page_count: document_page_count,
|
||
prefiltered_page_number_pages: None,
|
||
prefiltered_page_number_mask: None,
|
||
precomputed_chart_regions: None,
|
||
},
|
||
)
|
||
}
|
||
|
||
pub(crate) struct MarkdownDocumentContext<'a> {
|
||
pub(crate) page_thresholds: &'a HashMap<u32, f32>,
|
||
pub(crate) struct_roles:
|
||
Option<&'a HashMap<u32, HashMap<i64, crate::structure_tree::StructRole>>>,
|
||
pub(crate) struct_tables: &'a [crate::structure_tree::StructTable],
|
||
pub(crate) page_count: u32,
|
||
/// Pages where an upstream document-level pass removed folios. This keeps
|
||
/// table-continuation classification consistent after masked items drop.
|
||
pub(crate) prefiltered_page_number_pages: Option<&'a HashSet<u32>>,
|
||
/// Document-level removal decisions aligned with this call's input items.
|
||
/// Table detection consumes the original items; the mask is applied only
|
||
/// after table claims have been established.
|
||
pub(crate) prefiltered_page_number_mask: Option<&'a [bool]>,
|
||
/// Optional chart masks shared with layout analysis so the geometry is
|
||
/// detected once and interpreted identically by both pipelines.
|
||
pub(crate) precomputed_chart_regions: Option<&'a PageChartRegions>,
|
||
}
|
||
|
||
/// Convert positioned text items to markdown, using rectangles and line segments for table detection.
|
||
///
|
||
/// Line-based detection runs first (strongest structural evidence), then rect-based,
|
||
/// then heuristic fallback on unclaimed items.
|
||
pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||
items: Vec<TextItem>,
|
||
options: MarkdownOptions,
|
||
rects: &[crate::types::PdfRect],
|
||
pdf_lines: &[crate::types::PdfLine],
|
||
context: MarkdownDocumentContext<'_>,
|
||
) -> String {
|
||
use crate::tables::{
|
||
content_width, detect_tables_from_lines, detect_tables_from_rects,
|
||
detect_tables_from_struct_tree, detect_tables_with_page_width, try_build_rect_guided_table,
|
||
};
|
||
use crate::types::ItemType;
|
||
|
||
let MarkdownDocumentContext {
|
||
page_thresholds,
|
||
struct_roles,
|
||
struct_tables,
|
||
page_count: document_page_count,
|
||
prefiltered_page_number_pages,
|
||
prefiltered_page_number_mask,
|
||
precomputed_chart_regions,
|
||
} = context;
|
||
|
||
if items.is_empty() {
|
||
return String::new();
|
||
}
|
||
|
||
// Table detection must retain the original collection because short
|
||
// numeric table cells can be indistinguishable from folios until
|
||
// structural context is available. A precomputed mask carries the
|
||
// document-wide decision without removing items before table claims.
|
||
debug_assert!(prefiltered_page_number_mask.is_none_or(|mask| mask.len() == items.len()));
|
||
let has_precomputed_page_number_mask = prefiltered_page_number_mask.is_some();
|
||
let removed_page_number_pages = prefiltered_page_number_pages.cloned().unwrap_or_default();
|
||
|
||
// Separate images and links from text items
|
||
let mut images: Vec<TextItem> = Vec::new();
|
||
let mut page_image_regions: HashMap<u32, Vec<(f32, f32, f32, f32)>> = HashMap::new();
|
||
let mut links: Vec<TextItem> = Vec::new();
|
||
let mut text_items: Vec<TextItem> = Vec::new();
|
||
let mut text_item_page_number_mask: Vec<bool> = Vec::new();
|
||
|
||
for (input_index, item) in items.into_iter().enumerate() {
|
||
match &item.item_type {
|
||
ItemType::Image => {
|
||
page_image_regions.entry(item.page).or_default().push((
|
||
item.x,
|
||
item.y,
|
||
item.x + item.width,
|
||
item.y + item.height,
|
||
));
|
||
if options.include_images {
|
||
images.push(item);
|
||
}
|
||
}
|
||
ItemType::Link(_) => {
|
||
if options.include_links {
|
||
links.push(item);
|
||
}
|
||
}
|
||
ItemType::Text | ItemType::FormField => {
|
||
text_item_page_number_mask.push(
|
||
prefiltered_page_number_mask
|
||
.and_then(|mask| mask.get(input_index))
|
||
.copied()
|
||
.unwrap_or(false),
|
||
);
|
||
text_items.push(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Calculate base font size for table detection
|
||
let font_stats = calculate_font_stats_from_items(&text_items);
|
||
let base_size = options
|
||
.base_font_size
|
||
.unwrap_or(font_stats.most_common_size);
|
||
|
||
// Detect tables on each page
|
||
let mut table_items: HashSet<usize> = HashSet::new();
|
||
let mut page_tables: HashMap<u32, Vec<PositionedMarkdown>> = HashMap::new();
|
||
|
||
// Running headers/footers repeat verbatim at the same position on many
|
||
// pages. When such a block wraps a long title over aligned lines, the
|
||
// heuristic detector reads it as a table. Knowing which items are page
|
||
// furniture is a document-wide question, so answer it once here.
|
||
let running_furniture = running_furniture_keys(&text_items);
|
||
|
||
// Pre-group items by page with their global indices (O(n) instead of O(pages*n))
|
||
let mut page_groups: HashMap<u32, Vec<(usize, &TextItem)>> = HashMap::new();
|
||
for (global_idx, item) in text_items.iter().enumerate() {
|
||
page_groups
|
||
.entry(item.page)
|
||
.or_default()
|
||
.push((global_idx, item));
|
||
}
|
||
|
||
// Chart regions per page: their text must not steer column detection
|
||
// during line grouping (it fills the gutter and fuses two-column lines).
|
||
let page_chart_map = precomputed_chart_regions
|
||
.cloned()
|
||
.unwrap_or_else(|| chart_regions_by_page(&text_items, rects, pdf_lines));
|
||
|
||
let mut pages: Vec<u32> = page_groups.keys().copied().collect();
|
||
pages.sort();
|
||
|
||
// Track band splits per page so we can split non-table items later
|
||
let mut page_band_splits: HashMap<u32, Vec<(f32, f32)>> = HashMap::new();
|
||
// Chart pages with two prose columns use the chart's vertical span as a
|
||
// full-width separator and read each surrounding prose zone by column.
|
||
let mut page_chart_prose_splits: HashMap<u32, f32> = HashMap::new();
|
||
let mut page_chart_prose_orders: HashMap<u32, ChartProseOrder> = HashMap::new();
|
||
|
||
for page in pages {
|
||
let group = page_groups.get(&page).unwrap();
|
||
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
||
let page_content_width = content_width(&page_items);
|
||
|
||
// Chart-bar regions: bar charts drawn as filled rects read as cell
|
||
// rects or aligned text and get gridded into phantom tables. Their
|
||
// items are excluded from every table detector below and flow through
|
||
// as plain text instead.
|
||
let chart_regions: Vec<(f32, f32, f32, f32)> =
|
||
page_chart_map.get(&page).cloned().unwrap_or_default();
|
||
let in_chart = |item: &TextItem| item_is_in_chart_region(item, &chart_regions);
|
||
let page_layout_items = items_outside_chart_regions(&page_items, &chart_regions);
|
||
|
||
// Detect columns on chart-free text. Chart labels and values often fill
|
||
// the prose gutter, hiding real columns and allowing body text to reach
|
||
// heuristic table detection as one page-wide region.
|
||
let detected_columns = {
|
||
let cols = crate::extractor::detect_columns(&page_layout_items, page, false);
|
||
cols.len() >= 2
|
||
};
|
||
if !chart_regions.is_empty() {
|
||
log::debug!(
|
||
"page {}: {} chart region(s) masked from table detection",
|
||
page,
|
||
chart_regions.len()
|
||
);
|
||
}
|
||
|
||
// Repeated prose anchors provide a second, chart-scoped column signal.
|
||
// It does not partition table detection: a narrow or partly spanning
|
||
// gutter is too ambiguous for that. It can reject a body-font table
|
||
// hypothesis and later order prose within chart-separated zones.
|
||
// Multiple charts create several narrow vertical zones whose local
|
||
// column structure needs stronger region-graph reasoning. Keep those
|
||
// pages on the conservative full-page grouping path for now.
|
||
let chart_prose_split = chart_regions.first().and_then(|®ion| {
|
||
if chart_regions.len() != 1 {
|
||
return None;
|
||
}
|
||
chart_page_prose_column_split(&page_layout_items)
|
||
.filter(|&split_x| chart_spans_prose_split(region, split_x))
|
||
});
|
||
let chart_prose_columns = chart_prose_split.is_some();
|
||
|
||
// Check for side-by-side table layout using the original items. Sparse
|
||
// numeric cells need table context before they can be distinguished
|
||
// safely from folios; cleaned evidence is reserved for column and
|
||
// final non-table layout decisions.
|
||
let mut bands = split_side_by_side(&page_items);
|
||
// A rect table crossing a proposed split boundary means the "gutter"
|
||
// is really the gap between ruled and borderless table columns —
|
||
// splitting there cleaves the table in half. Veto the split.
|
||
if !bands.is_empty() && rect_cluster_spans_band_boundary(&page_items, rects, page, &bands) {
|
||
log::debug!(
|
||
"page {}: side-by-side split vetoed by spanning rect cluster",
|
||
page
|
||
);
|
||
bands.clear();
|
||
}
|
||
// Fallback: use rect hint regions to detect side-by-side layout
|
||
// when the text gap is too narrow for split_side_by_side to detect
|
||
// (e.g. calendars with left/right month columns ~10pt apart).
|
||
if bands.is_empty() {
|
||
bands = split_from_hint_regions(&page_items, rects, page);
|
||
// Only track hint-derived splits for non-table line grouping.
|
||
// split_side_by_side splits already scope table detection and
|
||
// their non-table items should flow through normal line grouping.
|
||
if !bands.is_empty() {
|
||
page_band_splits.insert(page, bands.clone());
|
||
}
|
||
}
|
||
// Anchor-derived prose splits are lower-confidence than physical
|
||
// gutters, so they do not partition table detection. They are applied
|
||
// later only to vertical zones outside the chart. Physical bands can
|
||
// still scope table detection without disabling this page-level prose
|
||
// and positioned-block order.
|
||
let chart_prose_order = chart_prose_split.and_then(|split_x| {
|
||
chart_regions.first().copied().map(|region| {
|
||
page_chart_prose_splits.insert(page, split_x);
|
||
let order = ChartProseOrder::new(split_x, region);
|
||
page_chart_prose_orders.insert(page, order);
|
||
order
|
||
})
|
||
});
|
||
|
||
// Build list of (band_items, band_index_map, band_rects, band_lines).
|
||
// band_index_map[local_band_idx] → page_items index.
|
||
let band_specs: Vec<BandSpec> = if bands.is_empty() {
|
||
// Single-region page — use all items/rects/lines as-is
|
||
let identity: Vec<usize> = (0..page_items.len()).collect();
|
||
vec![(
|
||
page_items.clone(),
|
||
identity,
|
||
rects.iter().filter(|r| r.page == page).cloned().collect(),
|
||
pdf_lines
|
||
.iter()
|
||
.filter(|l| l.page == page)
|
||
.cloned()
|
||
.collect(),
|
||
)]
|
||
} else {
|
||
bands
|
||
.iter()
|
||
.map(|&(x_lo, x_hi)| {
|
||
let margin = 2.0; // small margin to avoid clipping edge items
|
||
let (items_in_band, idx_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, item)| item.x >= x_lo - margin && item.x < x_hi + margin)
|
||
.map(|(idx, item)| (item.clone(), idx))
|
||
.unzip();
|
||
let band_rects = filter_rects_to_band(rects, page, x_lo, x_hi);
|
||
let band_lines = filter_lines_to_band(pdf_lines, page, x_lo, x_hi);
|
||
(items_in_band, idx_map, band_rects, band_lines)
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
// When the page is split into bands but no band produces a table,
|
||
// retry with all items merged as a single band. This handles
|
||
// borderless tables whose column alignment is misclassified as
|
||
// page-layout columns by split_side_by_side.
|
||
let was_split = band_specs.len() > 1;
|
||
log::debug!(
|
||
"page {}: {} bands (was_split={})",
|
||
page,
|
||
band_specs.len(),
|
||
was_split
|
||
);
|
||
let merged_band: BandSpec = if was_split {
|
||
let identity: Vec<usize> = (0..page_items.len()).collect();
|
||
(
|
||
page_items.clone(),
|
||
identity,
|
||
rects.iter().filter(|r| r.page == page).cloned().collect(),
|
||
pdf_lines
|
||
.iter()
|
||
.filter(|l| l.page == page)
|
||
.cloned()
|
||
.collect(),
|
||
)
|
||
} else {
|
||
(Vec::new(), Vec::new(), Vec::new(), Vec::new())
|
||
};
|
||
|
||
for (band_items, band_index_map, band_rects, band_lines) in &band_specs {
|
||
if band_items.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Track which band-local indices are claimed by structural detection
|
||
let mut rect_claimed: HashSet<usize> = HashSet::new();
|
||
|
||
// Pre-claim chart items: every detector below skips claimed
|
||
// indices, and unclaimed-by-tables text flows out as plain lines.
|
||
if !chart_regions.is_empty() {
|
||
for (idx, item) in band_items.iter().enumerate() {
|
||
if in_chart(item) {
|
||
rect_claimed.insert(idx);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 0. Structure-tree detection (highest priority — semantic PDF tagging)
|
||
// Only use struct-tree tables when they capture a majority (≥50%) of
|
||
// band items. Incomplete struct trees (partial tagging) should fall
|
||
// through to geometry detection which sees all items.
|
||
if !struct_tables.is_empty() {
|
||
let st_tables = detect_tables_from_struct_tree(band_items, struct_tables, page);
|
||
for table in &st_tables {
|
||
let coverage = table.item_indices.len() as f32 / band_items.len().max(1) as f32;
|
||
if coverage < 0.5 {
|
||
continue;
|
||
}
|
||
for &idx in &table.item_indices {
|
||
rect_claimed.insert(idx);
|
||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(table, chart_prose_order));
|
||
}
|
||
}
|
||
|
||
// 1. Rect-based detection (skips tables overlapping struct-tree claims)
|
||
let (rect_tables, hint_regions) =
|
||
detect_tables_from_rects(band_items, band_rects, page);
|
||
for table in &rect_tables {
|
||
if !rect_claimed.is_empty()
|
||
&& table
|
||
.item_indices
|
||
.iter()
|
||
.any(|idx| rect_claimed.contains(idx))
|
||
{
|
||
continue;
|
||
}
|
||
for &idx in &table.item_indices {
|
||
rect_claimed.insert(idx);
|
||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(table, chart_prose_order));
|
||
}
|
||
|
||
// 2. Line-based detection on unclaimed items (when rects didn't find tables)
|
||
if rect_claimed.is_empty() {
|
||
let line_tables = detect_tables_from_lines(band_items, band_lines, page);
|
||
for table in &line_tables {
|
||
for &idx in &table.item_indices {
|
||
rect_claimed.insert(idx);
|
||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(table, chart_prose_order));
|
||
}
|
||
}
|
||
|
||
// 3a. Try rect-guided table construction on hint regions before
|
||
// creating the heuristic closure (avoids borrow conflicts).
|
||
if rect_claimed.is_empty() && !hint_regions.is_empty() {
|
||
let padding = 15.0;
|
||
for hint in &hint_regions {
|
||
if hint.cluster_rects.is_empty() {
|
||
continue;
|
||
}
|
||
let (inside_items, inside_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, item)| {
|
||
item.y >= hint.y_bottom - padding
|
||
&& item.y <= hint.y_top + padding
|
||
&& item.x >= hint.x_left - padding
|
||
&& item.x <= hint.x_right + padding
|
||
})
|
||
.map(|(idx, item)| (item.clone(), idx))
|
||
.unzip();
|
||
|
||
if let Some(table) =
|
||
try_build_rect_guided_table(&inside_items, &hint.cluster_rects)
|
||
{
|
||
for &idx in &table.item_indices {
|
||
if let Some(&band_idx) = inside_map.get(idx) {
|
||
if let Some(&page_idx) = band_index_map.get(band_idx) {
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(&table, chart_prose_order));
|
||
for &band_idx in &inside_map {
|
||
rect_claimed.insert(band_idx);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3b. Heuristic fallback on unclaimed items
|
||
let mut run_heuristic =
|
||
|subset_items: &[TextItem], index_map: &[usize], min_items: usize| {
|
||
if subset_items.len() < min_items {
|
||
return;
|
||
}
|
||
// Keep body-font detection available on chart pages: a real
|
||
// table can share the prose anchors. Reject only candidates
|
||
// whose cells prove they are parallel prose fragments.
|
||
let reject_parallel_prose = chart_prose_columns && !was_split;
|
||
let tables = detect_tables_with_page_width(
|
||
subset_items,
|
||
base_size,
|
||
false,
|
||
page_content_width,
|
||
);
|
||
for table in tables {
|
||
if reject_parallel_prose && is_parallel_prose_table(&table) {
|
||
log::debug!(
|
||
"page {}: rejected {}x{} parallel-prose table hypothesis",
|
||
page,
|
||
table.rows.len(),
|
||
table.columns.len()
|
||
);
|
||
continue;
|
||
}
|
||
if is_running_furniture_table(subset_items, &table, &running_furniture) {
|
||
log::debug!(
|
||
"page {}: rejected {}x{} running header/footer table hypothesis",
|
||
page,
|
||
table.rows.len(),
|
||
table.columns.len()
|
||
);
|
||
continue;
|
||
}
|
||
for &idx in &table.item_indices {
|
||
if let Some(&band_idx) = index_map.get(idx) {
|
||
if let Some(&page_idx) = band_index_map.get(band_idx) {
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(&table, chart_prose_order));
|
||
}
|
||
};
|
||
|
||
// Run heuristic detection on unclaimed items
|
||
if rect_claimed.is_empty() && hint_regions.is_empty() {
|
||
// No rect tables or hints — run heuristic on all band items
|
||
let identity_map: Vec<usize> = (0..band_items.len()).collect();
|
||
run_heuristic(band_items, &identity_map, 6);
|
||
} else if rect_claimed.is_empty() && !hint_regions.is_empty() {
|
||
// No rect tables but hint regions exist — run heuristic separately
|
||
// on items inside each hint region and on items outside all hints.
|
||
let padding = 15.0;
|
||
for hint in &hint_regions {
|
||
let (inside_items, inside_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, item)| {
|
||
item.y >= hint.y_bottom - padding && item.y <= hint.y_top + padding
|
||
})
|
||
.map(|(idx, item)| (item.clone(), idx))
|
||
.unzip();
|
||
run_heuristic(&inside_items, &inside_map, 6);
|
||
for &band_idx in &inside_map {
|
||
rect_claimed.insert(band_idx);
|
||
}
|
||
}
|
||
let (outside_items, outside_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||
.map(|(idx, item)| (item.clone(), idx))
|
||
.unzip();
|
||
run_heuristic(&outside_items, &outside_map, 6);
|
||
} else {
|
||
// Rect tables found — run heuristic on unclaimed items
|
||
let (unclaimed_items, unclaimed_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||
.map(|(idx, item)| (item.clone(), idx))
|
||
.unzip();
|
||
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
|
||
}
|
||
|
||
// 4. Column-based table detection for borderless tabular layouts.
|
||
let band_has_tables = band_items.iter().enumerate().any(|(idx, _)| {
|
||
band_index_map
|
||
.get(idx)
|
||
.and_then(|&page_idx| group.get(page_idx))
|
||
.is_some_and(|&(global_idx, _)| table_items.contains(&global_idx))
|
||
});
|
||
let has_structural_elements = band_rects.len() >= 6 || band_lines.len() >= 4;
|
||
if !band_has_tables && !has_structural_elements {
|
||
if let Some(table) = crate::tables::try_build_table_from_columns(band_items, page) {
|
||
for &idx in &table.item_indices {
|
||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(&table, chart_prose_order));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Thin-rect border synthesis: last resort for PDFs that draw table
|
||
// borders as thin filled rectangles (common in spreadsheet exports).
|
||
// Only runs when ALL other methods found nothing on this page.
|
||
if !page_tables.contains_key(&page) {
|
||
let page_rects: Vec<&crate::types::PdfRect> =
|
||
rects.iter().filter(|r| r.page == page).collect();
|
||
let mut synth_lines: Vec<crate::types::PdfLine> = Vec::new();
|
||
for r in &page_rects {
|
||
let (mut w, mut h) = (r.width, r.height);
|
||
let (mut x, mut y) = (r.x, r.y);
|
||
if w < 0.0 {
|
||
x += w;
|
||
w = -w;
|
||
}
|
||
if h < 0.0 {
|
||
y += h;
|
||
h = -h;
|
||
}
|
||
if h < 2.0 && w >= 10.0 {
|
||
let mid_y = y + h / 2.0;
|
||
synth_lines.push(crate::types::PdfLine {
|
||
x1: x,
|
||
y1: mid_y,
|
||
x2: x + w,
|
||
y2: mid_y,
|
||
page,
|
||
});
|
||
} else if w < 2.0 && h >= 10.0 {
|
||
let mid_x = x + w / 2.0;
|
||
synth_lines.push(crate::types::PdfLine {
|
||
x1: mid_x,
|
||
y1: y,
|
||
x2: mid_x,
|
||
y2: y + h,
|
||
page,
|
||
});
|
||
}
|
||
}
|
||
if synth_lines.len() >= 10 {
|
||
// Chart text stays out of the thin-rect fallback too — a
|
||
// chart's thin grid rules would otherwise re-grid it.
|
||
let (page_text, page_text_map): (Vec<TextItem>, Vec<usize>) = text_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, i)| i.page == page && !in_chart(i))
|
||
.map(|(idx, i)| (i.clone(), idx))
|
||
.unzip();
|
||
let line_tables = detect_tables_from_lines(&page_text, &synth_lines, page);
|
||
for table in &line_tables {
|
||
for &idx in &table.item_indices {
|
||
if let Some(&global_idx) = page_text_map.get(idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(table, chart_prose_order));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Merged-band retry: if we split into bands but found no tables in
|
||
// any band, retry heuristic detection with all items as a single band.
|
||
// This catches borderless tables whose text-column alignment was
|
||
// misclassified as page-layout columns.
|
||
if was_split && !page_tables.contains_key(&page) && !merged_band.0.is_empty() {
|
||
let (ref band_items, ref band_index_map, _, _) = merged_band;
|
||
log::debug!(
|
||
"page {}: merged-band retry ({} items, was_split={})",
|
||
page,
|
||
band_items.len(),
|
||
was_split
|
||
);
|
||
// Chart text stays out of the retry as well.
|
||
let (chart_free, chart_free_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, it)| !in_chart(it))
|
||
.map(|(i, it)| (it.clone(), i))
|
||
.unzip();
|
||
// A chart-derived column signal must not disable body-font table
|
||
// detection during the merged-band retry: this retry exists for
|
||
// tables that only become visible after recombining false layout
|
||
// bands. Keep the legacy skip on ordinary detected-column pages,
|
||
// and reject chart-page prose candidates individually below.
|
||
let skip_body_font =
|
||
merged_retry_skips_body_font(detected_columns, !chart_regions.is_empty());
|
||
let heuristic_tables = detect_tables_with_page_width(
|
||
&chart_free,
|
||
base_size,
|
||
skip_body_font,
|
||
page_content_width,
|
||
);
|
||
for table in &heuristic_tables {
|
||
if !chart_regions.is_empty() && is_parallel_prose_table(table) {
|
||
log::debug!(
|
||
"page {}: rejected {}x{} merged-band parallel-prose table hypothesis",
|
||
page,
|
||
table.rows.len(),
|
||
table.columns.len()
|
||
);
|
||
continue;
|
||
}
|
||
if is_running_furniture_table(&chart_free, table, &running_furniture) {
|
||
log::debug!(
|
||
"page {}: rejected {}x{} merged-band running header/footer table hypothesis",
|
||
page,
|
||
table.rows.len(),
|
||
table.columns.len()
|
||
);
|
||
continue;
|
||
}
|
||
for &idx in &table.item_indices {
|
||
if let Some(&page_idx) = chart_free_map
|
||
.get(idx)
|
||
.and_then(|&band_idx| band_index_map.get(band_idx))
|
||
{
|
||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||
table_items.insert(global_idx);
|
||
}
|
||
}
|
||
}
|
||
page_tables
|
||
.entry(page)
|
||
.or_default()
|
||
.push(positioned_table(table, chart_prose_order));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Images are also removed before line grouping, so give them the same
|
||
// logical chart-page position as tables before reinsertion.
|
||
let mut page_images: HashMap<u32, Vec<PositionedMarkdown>> = HashMap::new();
|
||
for img in &images {
|
||
let img_name = img
|
||
.text
|
||
.strip_prefix("[Image: ")
|
||
.and_then(|s| s.strip_suffix(']'))
|
||
.unwrap_or(&img.text);
|
||
let img_md = format!("\n", img_name);
|
||
page_images
|
||
.entry(img.page)
|
||
.or_default()
|
||
.push(PositionedMarkdown::new(
|
||
img.y,
|
||
img.x,
|
||
img_md,
|
||
page_chart_prose_orders.get(&img.page).copied(),
|
||
));
|
||
}
|
||
|
||
// Check structure tree coverage on ALL text items (before table filtering)
|
||
// to decide whether to use structure-aware markdown generation.
|
||
let struct_roles_coverage_ok = struct_roles.is_some_and(|roles| {
|
||
let total = text_items.len();
|
||
if total == 0 {
|
||
return false;
|
||
}
|
||
let tagged = text_items
|
||
.iter()
|
||
.filter(|item| {
|
||
item.mcid
|
||
.and_then(|mcid| {
|
||
roles
|
||
.get(&item.page)
|
||
.and_then(|page_roles| page_roles.get(&mcid))
|
||
})
|
||
.is_some()
|
||
})
|
||
.count();
|
||
let coverage = tagged as f32 / total as f32;
|
||
log::debug!(
|
||
"structure tree coverage: {}/{} items ({:.0}%)",
|
||
tagged,
|
||
total,
|
||
coverage * 100.0
|
||
);
|
||
coverage >= 0.5
|
||
});
|
||
let effective_struct_roles = if struct_roles_coverage_ok {
|
||
struct_roles
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Filter out table items and process the rest
|
||
let non_table_items: Vec<(usize, TextItem)> = text_items
|
||
.into_iter()
|
||
.enumerate()
|
||
.filter(|(idx, _)| !table_items.contains(idx))
|
||
.collect();
|
||
|
||
// Find pages that are table-only (no remaining non-table text)
|
||
let table_only_pages: HashSet<u32> = {
|
||
let mut pages_with_text: HashSet<u32> =
|
||
non_table_items.iter().map(|(_, item)| item.page).collect();
|
||
// Preserve the pre-filter continuation classification: a page that
|
||
// originally also contained a folio does not become table-only merely
|
||
// because an upstream document-level pass removed it.
|
||
pages_with_text.extend(removed_page_number_pages);
|
||
page_tables
|
||
.keys()
|
||
.filter(|p| !pages_with_text.contains(p))
|
||
.copied()
|
||
.collect()
|
||
};
|
||
|
||
// Merge continuation tables across page breaks, but only for table-only pages
|
||
merge_continuation_tables(&mut page_tables, &table_only_pages);
|
||
|
||
// Collect pages that have detected tables — used to suppress relative valley
|
||
// column detection on pages where table column gaps would be misidentified.
|
||
let table_page_set: HashSet<u32> = page_tables.keys().copied().collect();
|
||
|
||
let non_table_items = if has_precomputed_page_number_mask {
|
||
non_table_items
|
||
.into_iter()
|
||
.filter(|(index, _)| !text_item_page_number_mask[*index])
|
||
.map(|(_, item)| item)
|
||
.collect()
|
||
} else {
|
||
crate::extractor::filter_markdown_page_numbers_with_removed_pages(
|
||
non_table_items.into_iter().map(|(_, item)| item).collect(),
|
||
document_page_count,
|
||
)
|
||
.0
|
||
};
|
||
|
||
// Split non-table items by band boundaries before line grouping so that
|
||
// items from different side-by-side zones (e.g. left/right month columns
|
||
// in a calendar) don't merge into the same line.
|
||
let lines = if page_band_splits.is_empty() && page_chart_prose_splits.is_empty() {
|
||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_regions(
|
||
non_table_items,
|
||
page_thresholds,
|
||
&table_page_set,
|
||
&page_chart_map,
|
||
&page_image_regions,
|
||
)
|
||
} else {
|
||
// Separate items into physical-band pages, chart/prose pages, and
|
||
// ordinary pages. Chart/prose pages need a different reading order:
|
||
// each chart is a full-width separator, while prose above and below
|
||
// it reads down the left column and then down the right column.
|
||
let mut split_page_items: HashMap<u32, Vec<TextItem>> = HashMap::new();
|
||
let mut chart_prose_page_items: HashMap<u32, Vec<TextItem>> = HashMap::new();
|
||
let mut unsplit_items: Vec<TextItem> = Vec::new();
|
||
for item in non_table_items {
|
||
if page_chart_prose_splits.contains_key(&item.page) {
|
||
chart_prose_page_items
|
||
.entry(item.page)
|
||
.or_default()
|
||
.push(item);
|
||
} else if page_band_splits.contains_key(&item.page) {
|
||
split_page_items.entry(item.page).or_default().push(item);
|
||
} else {
|
||
unsplit_items.push(item);
|
||
}
|
||
}
|
||
// Process unsplit pages normally
|
||
let mut all_lines =
|
||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_regions(
|
||
unsplit_items,
|
||
page_thresholds,
|
||
&table_page_set,
|
||
&page_chart_map,
|
||
&page_image_regions,
|
||
);
|
||
// Process each split page's bands independently, then interleave
|
||
// by Y position so paired zones (e.g. left/right months) appear together.
|
||
let mut split_pages: Vec<u32> = split_page_items.keys().copied().collect();
|
||
split_pages.sort();
|
||
for page in split_pages {
|
||
let items = split_page_items.remove(&page).unwrap();
|
||
let bands = &page_band_splits[&page];
|
||
let mut page_lines: Vec<crate::types::TextLine> = Vec::new();
|
||
for &(x_lo, x_hi) in bands {
|
||
let margin = 2.0;
|
||
let band_items: Vec<TextItem> = items
|
||
.iter()
|
||
.filter(|i| i.x >= x_lo - margin && i.x < x_hi + margin)
|
||
.cloned()
|
||
.collect();
|
||
if !band_items.is_empty() {
|
||
page_lines.extend(
|
||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||
band_items,
|
||
page_thresholds,
|
||
&table_page_set,
|
||
&page_chart_map,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
// Sort by Y descending (top to bottom) so left and right
|
||
// band lines interleave in visual reading order.
|
||
page_lines.sort_by(|a, b| b.y.total_cmp(&a.y));
|
||
all_lines.extend(page_lines);
|
||
}
|
||
|
||
// Process chart/prose pages as alternating vertical zones. Within a
|
||
// prose zone, group each column independently and append columns in
|
||
// newspaper order. Within a chart zone, group the full width normally.
|
||
let mut chart_prose_pages: Vec<u32> = chart_prose_page_items.keys().copied().collect();
|
||
chart_prose_pages.sort();
|
||
for page in chart_prose_pages {
|
||
let mut remaining = chart_prose_page_items.remove(&page).unwrap();
|
||
let split_x = page_chart_prose_splits[&page];
|
||
let chart_regions = &page_chart_map[&page];
|
||
let group_prose_zone = |zone_items: Vec<TextItem>| {
|
||
let mut zone_lines = Vec::new();
|
||
for right_column in [false, true] {
|
||
let column_items: Vec<TextItem> = zone_items
|
||
.iter()
|
||
.filter(|item| (item.x >= split_x) == right_column)
|
||
.cloned()
|
||
.collect();
|
||
if !column_items.is_empty() {
|
||
zone_lines.extend(
|
||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||
column_items,
|
||
page_thresholds,
|
||
&table_page_set,
|
||
&page_chart_map,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
zone_lines
|
||
};
|
||
|
||
let mut chart_y_bands: Vec<(f32, f32)> = page_chart_map[&page]
|
||
.iter()
|
||
.map(|&(_, y0, _, y1)| (y0 - CHART_SEPARATOR_PAD, y1 + CHART_SEPARATOR_PAD))
|
||
.collect();
|
||
chart_y_bands.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||
let mut merged_chart_y_bands: Vec<(f32, f32)> = Vec::new();
|
||
for (low, high) in chart_y_bands {
|
||
if let Some(last) = merged_chart_y_bands.last_mut() {
|
||
if high >= last.0 {
|
||
last.0 = last.0.min(low);
|
||
last.1 = last.1.max(high);
|
||
continue;
|
||
}
|
||
}
|
||
merged_chart_y_bands.push((low, high));
|
||
}
|
||
|
||
for (low, high) in merged_chart_y_bands {
|
||
let (above, at_or_below): (Vec<TextItem>, Vec<TextItem>) =
|
||
remaining.into_iter().partition(|item| {
|
||
item.y > high && !item_is_in_chart_region(item, chart_regions)
|
||
});
|
||
all_lines.extend(group_prose_zone(above));
|
||
|
||
let (chart_zone, below): (Vec<TextItem>, Vec<TextItem>) =
|
||
at_or_below.into_iter().partition(|item| {
|
||
item.y >= low || item_is_in_chart_region(item, chart_regions)
|
||
});
|
||
all_lines.extend(
|
||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||
chart_zone,
|
||
page_thresholds,
|
||
&table_page_set,
|
||
&page_chart_map,
|
||
),
|
||
);
|
||
remaining = below;
|
||
}
|
||
all_lines.extend(group_prose_zone(remaining));
|
||
}
|
||
// The three processing paths above are accumulated separately. Restore
|
||
// document page order while preserving each page's chosen line order.
|
||
all_lines.sort_by_key(|line| line.page);
|
||
all_lines
|
||
};
|
||
|
||
// Strip repeated headers/footers before conversion
|
||
let lines = if options.strip_headers_footers {
|
||
preprocess::strip_repeated_lines(lines, document_page_count)
|
||
} else {
|
||
lines
|
||
};
|
||
|
||
// Convert to markdown, inserting tables and images at appropriate positions
|
||
let mut band_split_page_set: HashSet<u32> = page_band_splits.keys().copied().collect();
|
||
band_split_page_set.extend(page_chart_prose_splits.keys().copied());
|
||
to_markdown_from_lines_with_tables_and_images(
|
||
lines,
|
||
options,
|
||
page_tables,
|
||
page_images,
|
||
&page_chart_map,
|
||
&band_split_page_set,
|
||
effective_struct_roles,
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use analysis::detect_header_level;
|
||
use classify::{is_code_like, is_list_item};
|
||
|
||
#[test]
|
||
fn test_is_list_item() {
|
||
assert!(is_list_item("• Item one"));
|
||
assert!(is_list_item("- Item two"));
|
||
assert!(is_list_item("* Item three"));
|
||
assert!(is_list_item("1. First"));
|
||
assert!(is_list_item("2) Second"));
|
||
assert!(is_list_item("a. Letter item"));
|
||
assert!(!is_list_item("Regular text"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_list_item() {
|
||
assert_eq!(format_list_item("• Item"), "- Item");
|
||
assert_eq!(format_list_item("- Item"), "- Item");
|
||
assert_eq!(format_list_item("1. First"), "1. First");
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_code_like() {
|
||
assert!(is_code_like("const x = 5;"));
|
||
assert!(is_code_like("function foo() {"));
|
||
assert!(is_code_like("import React from 'react'"));
|
||
assert!(!is_code_like("This is regular text."));
|
||
}
|
||
|
||
#[test]
|
||
fn test_detect_header_level() {
|
||
// With three tiers: 24→H1, 18→H2, 15→H3, 12→None
|
||
let tiers = vec![24.0, 18.0, 15.0];
|
||
assert_eq!(detect_header_level(24.0, 12.0, &tiers, false), Some(1));
|
||
assert_eq!(detect_header_level(18.0, 12.0, &tiers, false), Some(2));
|
||
assert_eq!(detect_header_level(15.0, 12.0, &tiers, false), Some(3));
|
||
assert_eq!(detect_header_level(12.0, 12.0, &tiers, false), None);
|
||
|
||
// Single tier: 15→H1 (ratio 1.25 ≥ 1.2), 14→None (ratio 1.17 < 1.2)
|
||
let tiers = vec![15.0];
|
||
assert_eq!(detect_header_level(15.0, 12.0, &tiers, false), Some(1));
|
||
assert_eq!(detect_header_level(14.0, 12.0, &tiers, false), None);
|
||
assert_eq!(detect_header_level(12.0, 12.0, &tiers, false), None);
|
||
|
||
// No tiers (empty): falls back to ratio thresholds
|
||
let tiers: Vec<f32> = vec![];
|
||
assert_eq!(detect_header_level(24.0, 12.0, &tiers, false), Some(1));
|
||
assert_eq!(detect_header_level(18.0, 12.0, &tiers, false), Some(2));
|
||
assert_eq!(detect_header_level(15.0, 12.0, &tiers, false), Some(3));
|
||
assert_eq!(detect_header_level(14.5, 12.0, &tiers, false), Some(4));
|
||
assert_eq!(detect_header_level(14.0, 12.0, &tiers, false), None);
|
||
assert_eq!(detect_header_level(12.0, 12.0, &tiers, false), None);
|
||
|
||
// Body text excluded when tiers exist: 13pt (ratio 1.08) → None
|
||
let tiers = vec![20.0];
|
||
assert_eq!(detect_header_level(13.0, 12.0, &tiers, false), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_to_markdown() {
|
||
let text = "• First item\n• Second item\n\nRegular paragraph.";
|
||
let md = to_markdown(text, MarkdownOptions::default());
|
||
assert!(md.contains("- First item"));
|
||
assert!(md.contains("- Second item"));
|
||
}
|
||
|
||
fn furniture_item(text: &str, x: f32, y: f32, page: u32) -> TextItem {
|
||
let mut it = make_item(x, y, page);
|
||
it.text = text.into();
|
||
it
|
||
}
|
||
|
||
/// Items repeating verbatim at the same position on 3+ pages are running
|
||
/// furniture; the same text on fewer pages, or at different positions, is
|
||
/// not.
|
||
#[test]
|
||
fn running_furniture_requires_three_pages_at_same_position() {
|
||
let mut items = Vec::new();
|
||
for page in 1..=3 {
|
||
// Body content so each page has a real vertical extent.
|
||
items.push(furniture_item("body", 85.0, 700.0, page));
|
||
items.push(furniture_item("TITULAR DEL", 85.0, 68.0, page));
|
||
}
|
||
// Same text but only two pages.
|
||
for page in 1..=2 {
|
||
items.push(furniture_item("SECRETARÍA", 200.0, 68.0, page));
|
||
}
|
||
// Same text on three pages but at drifting positions.
|
||
for (page, x) in [(1, 300.0), (2, 320.0), (3, 340.0)] {
|
||
items.push(furniture_item("MÉXICO", x, 68.0, page));
|
||
}
|
||
|
||
let running = running_furniture_keys(&items);
|
||
assert!(running.contains(&furniture_key(&furniture_item(
|
||
"TITULAR DEL",
|
||
85.0,
|
||
68.0,
|
||
1
|
||
))));
|
||
assert!(!running.contains(&furniture_key(&furniture_item(
|
||
"SECRETARÍA",
|
||
200.0,
|
||
68.0,
|
||
1
|
||
))));
|
||
assert!(!running.contains(&furniture_key(&furniture_item("MÉXICO", 300.0, 68.0, 1))));
|
||
}
|
||
|
||
/// A table made of running-footer items is vetoed; a table whose body rows
|
||
/// carry per-page content is kept even when its header row repeats.
|
||
#[test]
|
||
fn running_furniture_table_veto() {
|
||
// The footer block, present identically on pages 1-3.
|
||
let mut items = Vec::new();
|
||
for page in 1..=3 {
|
||
items.push(furniture_item("PROPOSICIÓN CON PUNTO", 85.0, 78.5, page));
|
||
items.push(furniture_item("EL SENADO", 286.6, 78.5, page));
|
||
items.push(furniture_item("TITULAR DEL", 85.0, 68.0, page));
|
||
items.push(furniture_item("A TRAVÉS DE LA", 243.4, 68.0, page));
|
||
}
|
||
// A real table on page 1: repeated header row, per-page data rows.
|
||
let header = [
|
||
furniture_item("Year", 85.0, 500.0, 1),
|
||
furniture_item("Total", 200.0, 500.0, 1),
|
||
];
|
||
let data = [
|
||
furniture_item("2023", 85.0, 488.0, 1),
|
||
furniture_item("1,204", 200.0, 488.0, 1),
|
||
furniture_item("2024", 85.0, 476.0, 1),
|
||
furniture_item("1,377", 200.0, 476.0, 1),
|
||
];
|
||
// Header repeats on every page (like a continued table's header).
|
||
for page in 2..=3 {
|
||
items.push(furniture_item("Year", 85.0, 500.0, page));
|
||
items.push(furniture_item("Total", 200.0, 500.0, page));
|
||
}
|
||
items.extend(header.iter().cloned());
|
||
items.extend(data.iter().cloned());
|
||
|
||
let running = running_furniture_keys(&items);
|
||
|
||
let table_of = |detection_items: &[TextItem]| crate::tables::Table {
|
||
columns: vec![],
|
||
rows: vec![],
|
||
cells: vec![],
|
||
item_indices: (0..detection_items.len()).collect(),
|
||
kind: crate::tables::TableKind::Data,
|
||
};
|
||
|
||
// Footer-only candidate: every item is furniture -> vetoed.
|
||
let footer_items: Vec<TextItem> = (1..=1)
|
||
.flat_map(|page| {
|
||
vec![
|
||
furniture_item("PROPOSICIÓN CON PUNTO", 85.0, 78.5, page),
|
||
furniture_item("EL SENADO", 286.6, 78.5, page),
|
||
furniture_item("TITULAR DEL", 85.0, 68.0, page),
|
||
furniture_item("A TRAVÉS DE LA", 243.4, 68.0, page),
|
||
]
|
||
})
|
||
.collect();
|
||
assert!(is_running_furniture_table(
|
||
&footer_items,
|
||
&table_of(&footer_items),
|
||
&running
|
||
));
|
||
|
||
// Real table: header row repeats across pages, body rows do not ->
|
||
// 2 furniture of 6 items (33%) stays under the 80% threshold.
|
||
let real_items: Vec<TextItem> =
|
||
header.iter().cloned().chain(data.iter().cloned()).collect();
|
||
assert!(!is_running_furniture_table(
|
||
&real_items,
|
||
&table_of(&real_items),
|
||
&running
|
||
));
|
||
}
|
||
|
||
/// A form template repeated per record carries identical labels at
|
||
/// identical mid-page coordinates on every page — those are real table
|
||
/// cells, not furniture. Only the page-edge bands qualify.
|
||
#[test]
|
||
fn mid_page_repetition_is_not_furniture() {
|
||
let mut items = Vec::new();
|
||
for page in 1..=4 {
|
||
// Content spanning the page: y 60 (bottom) to 740 (top).
|
||
items.push(furniture_item("body top", 85.0, 740.0, page));
|
||
items.push(furniture_item("body bottom", 85.0, 60.0, page));
|
||
// Form labels repeated dead centre on every page.
|
||
items.push(furniture_item("Name of creditor", 85.0, 400.0, page));
|
||
items.push(furniture_item("Amount of claim", 300.0, 400.0, page));
|
||
// A genuine footer inside the bottom band.
|
||
items.push(furniture_item("FORM 78 — page footer", 85.0, 70.0, page));
|
||
}
|
||
|
||
let running = running_furniture_keys(&items);
|
||
assert!(
|
||
!running.contains(&furniture_key(&furniture_item(
|
||
"Name of creditor",
|
||
85.0,
|
||
400.0,
|
||
1
|
||
))),
|
||
"mid-page form labels must not be furniture"
|
||
);
|
||
assert!(running.contains(&furniture_key(&furniture_item(
|
||
"FORM 78 — page footer",
|
||
85.0,
|
||
70.0,
|
||
1
|
||
))));
|
||
}
|
||
|
||
#[test]
|
||
fn running_furniture_empty_on_short_documents() {
|
||
let mut items = Vec::new();
|
||
for page in 1..=2 {
|
||
items.push(furniture_item("body", 85.0, 700.0, page));
|
||
items.push(furniture_item("FOOTER", 85.0, 68.0, page));
|
||
}
|
||
assert!(running_furniture_keys(&items).is_empty());
|
||
}
|
||
|
||
/// A page whose text has no vertical span (a single line) gives no
|
||
/// evidence of where its edges are; its items never become furniture.
|
||
#[test]
|
||
fn zero_span_page_contributes_no_furniture() {
|
||
let mut items = Vec::new();
|
||
for page in 1..=4 {
|
||
items.push(furniture_item("ROW LABEL", 85.0, 400.0, page));
|
||
items.push(furniture_item("ROW VALUE", 300.0, 400.0, page));
|
||
}
|
||
assert!(running_furniture_keys(&items).is_empty());
|
||
}
|
||
|
||
fn make_item(x: f32, y: f32, page: u32) -> TextItem {
|
||
TextItem {
|
||
text: "A".into(),
|
||
x,
|
||
y,
|
||
width: 5.0,
|
||
height: 10.0,
|
||
font: String::new(),
|
||
font_size: 10.0,
|
||
page,
|
||
is_bold: false,
|
||
is_italic: false,
|
||
is_underline: false,
|
||
is_strikeout: false,
|
||
item_type: crate::types::ItemType::Text,
|
||
mcid: None,
|
||
}
|
||
}
|
||
|
||
fn make_item_w(x: f32, y: f32, width: f32, page: u32) -> TextItem {
|
||
let mut it = make_item(x, y, page);
|
||
it.width = width;
|
||
it
|
||
}
|
||
|
||
#[test]
|
||
fn precomputed_folio_mask_preserves_numeric_table_cells() {
|
||
let mut items = Vec::new();
|
||
let mut rects = Vec::new();
|
||
for row in 0..4 {
|
||
for column in 0..2 {
|
||
let mut item = make_item_w(
|
||
110.0 + column as f32 * 100.0,
|
||
30.0 + row as f32 * 20.0,
|
||
20.0,
|
||
1,
|
||
);
|
||
item.text = (row * 2 + column + 1).to_string();
|
||
items.push(item);
|
||
rects.push(PdfRect {
|
||
x: 100.0 + column as f32 * 100.0,
|
||
y: 20.0 + row as f32 * 20.0,
|
||
width: 100.0,
|
||
height: 20.0,
|
||
page: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Simulate document-level folio decisions that would remove every
|
||
// short numeric item if applied before structural table detection.
|
||
let removal_mask = vec![true; items.len()];
|
||
let removed_pages = HashSet::from([1]);
|
||
let markdown = to_markdown_from_items_with_rects_and_lines(
|
||
items,
|
||
MarkdownOptions::default(),
|
||
&rects,
|
||
&[],
|
||
MarkdownDocumentContext {
|
||
page_thresholds: &HashMap::new(),
|
||
struct_roles: None,
|
||
struct_tables: &[],
|
||
page_count: 1,
|
||
prefiltered_page_number_pages: Some(&removed_pages),
|
||
prefiltered_page_number_mask: Some(&removal_mask),
|
||
precomputed_chart_regions: None,
|
||
},
|
||
);
|
||
|
||
assert!(markdown.contains("|1|2|"), "{markdown}");
|
||
assert!(markdown.contains("|7|8|"), "{markdown}");
|
||
}
|
||
|
||
#[test]
|
||
fn early_layout_excludes_chart_items_before_column_detection() {
|
||
let mut items = Vec::new();
|
||
for row in 0..20 {
|
||
let y = 100.0 + row as f32 * 14.0;
|
||
items.push(make_item_w(90.0, y, 180.0, 1));
|
||
items.push(make_item_w(340.0, y, 180.0, 1));
|
||
}
|
||
for col in 0..12 {
|
||
items.push(make_item_w(
|
||
100.0 + col as f32 * 35.0,
|
||
620.0 - col as f32 * 4.0,
|
||
25.0,
|
||
1,
|
||
));
|
||
}
|
||
|
||
let chart_regions = vec![(90.0, 540.0, 530.0, 700.0)];
|
||
let layout_items = items_outside_chart_regions(&items, &chart_regions);
|
||
|
||
assert_eq!(layout_items.len(), 40);
|
||
assert_eq!(
|
||
crate::extractor::detect_columns(&layout_items, 1, false).len(),
|
||
2
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn chart_padding_claims_labels_but_not_adjacent_prose() {
|
||
let regions = vec![(100.0, 100.0, 500.0, 300.0)];
|
||
|
||
let mut label = make_item_w(220.0, 90.0, 60.0, 1);
|
||
label.text = "January 2021".into();
|
||
assert!(item_is_in_chart_region(&label, ®ions));
|
||
|
||
let mut long_label = make_item_w(210.0, 90.0, 180.0, 1);
|
||
long_label.text = "Share of respondents by employment sector".into();
|
||
assert!(item_is_in_chart_region(&long_label, ®ions));
|
||
|
||
let mut caption = make_item_w(120.0, 310.0, 350.0, 1);
|
||
caption.text = "Figure 3. Results across every survey phase and sector".into();
|
||
assert!(item_is_in_chart_region(&caption, ®ions));
|
||
|
||
let mut wide_short_prose = make_item_w(120.0, 90.0, 350.0, 1);
|
||
wide_short_prose.text = "Results improved across all sectors".into();
|
||
assert!(!item_is_in_chart_region(&wide_short_prose, ®ions));
|
||
|
||
let mut prose = make_item_w(120.0, 90.0, 350.0, 1);
|
||
prose.text = "This paragraph continues below the chart into the next prose column".into();
|
||
assert!(!item_is_in_chart_region(&prose, ®ions));
|
||
|
||
let mut bullet = make_item_w(340.0, 90.0, 5.0, 1);
|
||
bullet.text = "•".into();
|
||
assert!(!item_is_in_chart_region(&bullet, ®ions));
|
||
}
|
||
|
||
#[test]
|
||
fn chart_page_detects_two_short_prose_columns() {
|
||
let mut items = Vec::new();
|
||
for row in 0..12 {
|
||
let y = 100.0 + row as f32 * 14.0;
|
||
let mut left = make_item_w(90.0, y, 180.0, 1);
|
||
left.text = "A complete prose line in the left column".into();
|
||
items.push(left);
|
||
let mut right = make_item_w(340.0, y, 180.0, 1);
|
||
right.text = "A complete prose line in the right column".into();
|
||
items.push(right);
|
||
}
|
||
|
||
let split = chart_page_prose_column_split(&items).unwrap();
|
||
assert!(split > 200.0 && split < 300.0);
|
||
}
|
||
|
||
#[test]
|
||
fn chart_page_rejects_non_overlapping_prose_anchors() {
|
||
let mut items = Vec::new();
|
||
for row in 0..8 {
|
||
let mut left = make_item_w(90.0, 100.0 + row as f32 * 14.0, 180.0, 1);
|
||
left.text = "A complete prose line in the upper column".into();
|
||
items.push(left);
|
||
let mut right = make_item_w(340.0, 300.0 + row as f32 * 14.0, 180.0, 1);
|
||
right.text = "A complete prose line in the lower column".into();
|
||
items.push(right);
|
||
}
|
||
|
||
assert_eq!(chart_page_prose_column_split(&items), None);
|
||
}
|
||
|
||
#[test]
|
||
fn chart_separator_must_span_both_prose_columns() {
|
||
let split_x = 280.0;
|
||
|
||
assert!(chart_spans_prose_split(
|
||
(100.0, 300.0, 500.0, 500.0),
|
||
split_x
|
||
));
|
||
assert!(!chart_spans_prose_split(
|
||
(90.0, 300.0, 260.0, 500.0),
|
||
split_x
|
||
));
|
||
assert!(!chart_spans_prose_split(
|
||
(300.0, 300.0, 520.0, 500.0),
|
||
split_x
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn chart_prose_order_survives_physical_gutter_detection() {
|
||
let mut items = Vec::new();
|
||
for row in 0..20 {
|
||
let y = 760.0 - row as f32 * 13.0;
|
||
let mut left = make_item_w(90.0, y, 100.0, 1);
|
||
left.text = format!("Left prose line {row} has several words");
|
||
items.push(left);
|
||
let mut right = make_item_w(340.0, y, 100.0, 1);
|
||
right.text = format!("Right prose line {row} has several words");
|
||
items.push(right);
|
||
}
|
||
assert!(
|
||
!split_side_by_side(&items).is_empty(),
|
||
"fixture must exercise the physical-gutter path"
|
||
);
|
||
|
||
// Connected, variably sized bars form a full-width chart separator.
|
||
let mut rects = vec![PdfRect {
|
||
x: 80.0,
|
||
y: 280.0,
|
||
width: 440.0,
|
||
height: 150.0,
|
||
page: 1,
|
||
}];
|
||
for (x, height) in [(120.0, 55.0), (210.0, 90.0), (300.0, 70.0), (390.0, 115.0)] {
|
||
rects.push(PdfRect {
|
||
x,
|
||
y: 280.0,
|
||
width: 45.0,
|
||
height,
|
||
page: 1,
|
||
});
|
||
}
|
||
// A second connected family keeps the bar cluster above the detector's
|
||
// minimum while retaining data-driven height variation.
|
||
for (x, y, height) in [
|
||
(120.0, 332.0, 45.0),
|
||
(210.0, 367.0, 55.0),
|
||
(300.0, 347.0, 35.0),
|
||
(390.0, 392.0, 30.0),
|
||
] {
|
||
rects.push(PdfRect {
|
||
x,
|
||
y,
|
||
width: 45.0,
|
||
height,
|
||
page: 1,
|
||
});
|
||
}
|
||
|
||
// Detection/input order is deliberately right before left. Only the
|
||
// chart-scoped logical order can put these blocks into prose-column
|
||
// order after the physical bands have already scoped table detection.
|
||
let mut right_image = make_item(340.0, 650.0, 1);
|
||
right_image.text = "[Image: RightFigure]".into();
|
||
right_image.item_type = crate::types::ItemType::Image;
|
||
items.push(right_image);
|
||
let mut left_image = make_item(90.0, 600.0, 1);
|
||
left_image.text = "[Image: LeftFigure]".into();
|
||
left_image.item_type = crate::types::ItemType::Image;
|
||
items.push(left_image);
|
||
|
||
let options = MarkdownOptions {
|
||
include_images: true,
|
||
..MarkdownOptions::default()
|
||
};
|
||
let markdown = to_markdown_from_items_with_rects(items, options, &rects);
|
||
let left_last = markdown.find("Left prose line 19").unwrap();
|
||
let right_first = markdown.find("Right prose line 0").unwrap();
|
||
assert!(
|
||
left_last < right_first,
|
||
"chart prose must read down the left column before the right even when table detection found physical bands:\n{markdown}"
|
||
);
|
||
let left_image = markdown.find("LeftFigure").unwrap();
|
||
let right_image = markdown.find("RightFigure").unwrap();
|
||
assert!(
|
||
left_image < right_image,
|
||
"chart blocks must retain column order when physical bands are present:\n{markdown}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parallel_prose_table_is_rejected_but_real_table_is_preserved() {
|
||
assert!(merged_retry_skips_body_font(true, false));
|
||
assert!(!merged_retry_skips_body_font(true, true));
|
||
assert!(!merged_retry_skips_body_font(false, true));
|
||
|
||
assert!(looks_like_numbered_section_heading(
|
||
"9.5. Adapting to the New Normal: Changing Business Models"
|
||
));
|
||
assert!(!looks_like_numbered_section_heading(
|
||
"2024 revenue by business segment"
|
||
));
|
||
|
||
let prose = crate::tables::Table::new(
|
||
vec![90.0, 340.0],
|
||
vec![320.0, 300.0, 280.0, 260.0],
|
||
vec![
|
||
vec![
|
||
"This section investigates the impact of public health measures".into(),
|
||
"course of the research period and the impacts continued".into(),
|
||
],
|
||
vec![
|
||
"measures on business operations during the national lockdown".into(),
|
||
"".into(),
|
||
],
|
||
vec![
|
||
"asked about their expectations for business recovery".into(),
|
||
"felt by firms working under reduced operating conditions".into(),
|
||
],
|
||
vec![
|
||
"respondents described their expectations for business recovery".into(),
|
||
"while many other businesses remained temporarily closed".into(),
|
||
],
|
||
],
|
||
(0..7).collect(),
|
||
);
|
||
assert!(is_parallel_prose_table(&prose));
|
||
|
||
let data = crate::tables::Table::new(
|
||
vec![90.0, 340.0],
|
||
vec![300.0, 280.0, 260.0],
|
||
vec![
|
||
vec!["Sector".into(), "Revenue".into()],
|
||
vec!["Tourism".into(), "$1,240".into()],
|
||
vec!["Agriculture".into(), "$980".into()],
|
||
],
|
||
(0..6).collect(),
|
||
);
|
||
assert!(!is_parallel_prose_table(&data));
|
||
|
||
let headed_text_table = crate::tables::Table::new(
|
||
vec![90.0, 340.0],
|
||
vec![320.0, 300.0, 280.0],
|
||
vec![
|
||
vec!["Program".into(), "Description".into()],
|
||
vec![
|
||
"Business recovery and continuity planning support".into(),
|
||
"Provides tailored guidance to firms affected by disruptions".into(),
|
||
],
|
||
vec![
|
||
"Regional market access and supplier development".into(),
|
||
"Connects eligible producers with new distribution partners".into(),
|
||
],
|
||
],
|
||
(0..6).collect(),
|
||
);
|
||
assert!(!is_parallel_prose_table(&headed_text_table));
|
||
|
||
let sparse_first_row_then_compact_body = crate::tables::Table::new(
|
||
vec![90.0, 340.0],
|
||
vec![340.0, 320.0, 300.0, 280.0],
|
||
vec![
|
||
vec![
|
||
"This introductory prose fragment occupies only the left column".into(),
|
||
"".into(),
|
||
],
|
||
vec!["short".into(), "row".into()],
|
||
vec![
|
||
"continuation text remains aligned with the left prose anchor".into(),
|
||
"parallel text continues down the right prose column".into(),
|
||
],
|
||
vec![
|
||
"another wrapped fragment follows in the left column".into(),
|
||
"while its neighboring prose fragment continues on the right".into(),
|
||
],
|
||
],
|
||
(0..8).collect(),
|
||
);
|
||
assert!(is_parallel_prose_table(&sparse_first_row_then_compact_body));
|
||
|
||
let headerless_description_table = crate::tables::Table::new(
|
||
vec![90.0, 340.0],
|
||
vec![320.0, 300.0, 280.0],
|
||
vec![
|
||
vec![
|
||
"community preparedness and emergency response planning".into(),
|
||
"provides detailed support for local continuity programs".into(),
|
||
],
|
||
vec![
|
||
"regional supplier and market development assistance".into(),
|
||
"connects eligible producers with new distribution partners".into(),
|
||
],
|
||
vec![
|
||
"financial continuity and business recovery program".into(),
|
||
"offers tailored guidance to firms affected by disruptions".into(),
|
||
],
|
||
],
|
||
(0..6).collect(),
|
||
);
|
||
assert!(!is_parallel_prose_table(&headerless_description_table));
|
||
|
||
let sparse_rowspanning_description_table = crate::tables::Table::new(
|
||
vec![90.0, 340.0],
|
||
vec![360.0, 340.0, 320.0, 300.0, 280.0, 260.0],
|
||
vec![
|
||
vec![
|
||
"community preparedness and emergency response planning support".into(),
|
||
"provides detailed support for local continuity program delivery".into(),
|
||
],
|
||
vec![
|
||
"through coordinated training and regional response exercises".into(),
|
||
"".into(),
|
||
],
|
||
vec![
|
||
"".into(),
|
||
"with technical assistance for participating local organizations".into(),
|
||
],
|
||
vec![
|
||
"regional supplier and market development assistance program".into(),
|
||
"connects eligible producers with new distribution opportunities".into(),
|
||
],
|
||
vec![
|
||
"through procurement guidance and tailored readiness workshops".into(),
|
||
"".into(),
|
||
],
|
||
vec![
|
||
"".into(),
|
||
"while expanding access to qualified commercial partners".into(),
|
||
],
|
||
],
|
||
(0..8).collect(),
|
||
);
|
||
assert!(!is_parallel_prose_table(
|
||
&sparse_rowspanning_description_table
|
||
));
|
||
}
|
||
|
||
/// 4-row × 2-col ruled grid from x=100..300 (rows every 20pt from y=600).
|
||
fn ruled_cluster_rects() -> Vec<PdfRect> {
|
||
let mut rects = Vec::new();
|
||
for row in 0..4 {
|
||
for col in 0..2 {
|
||
rects.push(PdfRect {
|
||
x: 100.0 + col as f32 * 100.0,
|
||
y: 600.0 + row as f32 * 20.0,
|
||
width: 100.0,
|
||
height: 20.0,
|
||
page: 1,
|
||
});
|
||
}
|
||
}
|
||
rects
|
||
}
|
||
|
||
#[test]
|
||
fn band_veto_cluster_ruled_across_boundary() {
|
||
// Rects on both sides of the boundary and cell text on both sides,
|
||
// row-aligned → the split cuts straight through a drawn table.
|
||
let mut rects = ruled_cluster_rects();
|
||
for r in &mut rects {
|
||
r.width = 150.0; // right column now spans 250..400, past b=320
|
||
}
|
||
let mut items = Vec::new();
|
||
for row in 0..4 {
|
||
let y = 610.0 + row as f32 * 20.0;
|
||
items.push(make_item_w(110.0, y, 80.0, 1)); // left cells
|
||
items.push(make_item_w(330.0, y, 30.0, 1)); // right cells past b
|
||
}
|
||
assert!(rect_cluster_spans_band_boundary(
|
||
&items,
|
||
&rects,
|
||
1,
|
||
&[(90.0, 320.0), (320.0, 500.0)]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn band_veto_ignores_spanning_figure() {
|
||
// A figure's rects span the boundary at the top of the page, but the
|
||
// far side is dominated by column prose below it → keep the split.
|
||
let mut rects = ruled_cluster_rects(); // y 600..680
|
||
for r in &mut rects {
|
||
r.width = 150.0; // spans past b=320
|
||
}
|
||
let mut items = Vec::new();
|
||
// A few figure labels inside the cluster, aligned rows.
|
||
for row in 0..4 {
|
||
let y = 610.0 + row as f32 * 20.0;
|
||
items.push(make_item_w(110.0, y, 40.0, 1));
|
||
items.push(make_item_w(330.0, y, 20.0, 1));
|
||
}
|
||
// Dense prose column far below the figure (outside cluster y-range).
|
||
let mut y = 100.0;
|
||
while y < 560.0 {
|
||
items.push(make_item_w(330.0, y, 140.0, 1));
|
||
y += 12.0;
|
||
}
|
||
assert!(!rect_cluster_spans_band_boundary(
|
||
&items,
|
||
&rects,
|
||
1,
|
||
&[(90.0, 320.0), (320.0, 500.0)]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn band_veto_borderless_columns_continue_rows() {
|
||
// Rects end at x=300 (just short of b=320); cell-like text at x=340
|
||
// aligns with every grid row → the "gutter" is inside the table.
|
||
let rects = ruled_cluster_rects();
|
||
let mut items = Vec::new();
|
||
for row in 0..4 {
|
||
let y = 610.0 + row as f32 * 20.0;
|
||
items.push(make_item_w(110.0, y, 80.0, 1)); // label cells
|
||
items.push(make_item_w(340.0, y, 30.0, 1)); // borderless column
|
||
}
|
||
assert!(rect_cluster_spans_band_boundary(
|
||
&items,
|
||
&rects,
|
||
1,
|
||
&[(90.0, 320.0), (320.0, 500.0)]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn band_veto_ignores_prose_column() {
|
||
// Dense prose right of the boundary: wide lines, three per grid row,
|
||
// mostly not row-aligned → keep the side-by-side split.
|
||
let rects = ruled_cluster_rects();
|
||
let mut items = Vec::new();
|
||
for row in 0..4 {
|
||
items.push(make_item_w(110.0, 610.0 + row as f32 * 20.0, 80.0, 1));
|
||
}
|
||
let mut y = 602.0;
|
||
while y < 680.0 {
|
||
items.push(make_item_w(340.0, y, 200.0, 1)); // full-width prose lines
|
||
y += 7.0;
|
||
}
|
||
assert!(!rect_cluster_spans_band_boundary(
|
||
&items,
|
||
&rects,
|
||
1,
|
||
&[(90.0, 320.0), (320.0, 500.0)]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn split_from_hint_regions_too_few_rects() {
|
||
// Fewer than 60 rects → no split
|
||
let items = vec![make_item(10.0, 100.0, 1)];
|
||
let rects: Vec<PdfRect> = (0..30)
|
||
.map(|i| PdfRect {
|
||
x: 10.0 + (i % 7) as f32 * 15.0,
|
||
y: 100.0 + (i / 7) as f32 * 15.0,
|
||
width: 10.0,
|
||
height: 10.0,
|
||
page: 1,
|
||
})
|
||
.collect();
|
||
assert!(split_from_hint_regions(&items, &rects, 1).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn split_from_hint_regions_no_pairs() {
|
||
// Enough rects but all in one X zone → no left/right pairs → no split
|
||
let items = vec![make_item(10.0, 100.0, 1)];
|
||
// 80 rects all in left half
|
||
let rects: Vec<PdfRect> = (0..80)
|
||
.map(|i| PdfRect {
|
||
x: 10.0 + (i % 10) as f32 * 15.0,
|
||
y: 100.0 + (i / 10) as f32 * 15.0,
|
||
width: 10.0,
|
||
height: 10.0,
|
||
page: 1,
|
||
})
|
||
.collect();
|
||
assert!(split_from_hint_regions(&items, &rects, 1).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn no_split_label_plus_number_table() {
|
||
// Balance sheet layout: text labels on left, numbers on right.
|
||
// Should NOT split because it's one table, not side-by-side regions.
|
||
let mut items = Vec::new();
|
||
for row in 0..30 {
|
||
// Label at x=50
|
||
let mut label = make_item(50.0, 700.0 - row as f32 * 15.0, 1);
|
||
label.text = format!("Row label {}", row);
|
||
label.width = 100.0;
|
||
items.push(label);
|
||
// Number at x=400
|
||
let mut num1 = make_item(400.0, 700.0 - row as f32 * 15.0, 1);
|
||
num1.text = format!("{},000.0", 100 + row);
|
||
num1.width = 50.0;
|
||
items.push(num1);
|
||
// Number at x=470
|
||
let mut num2 = make_item(470.0, 700.0 - row as f32 * 15.0, 1);
|
||
num2.text = format!("{},500.0", 200 + row);
|
||
num2.width = 50.0;
|
||
items.push(num2);
|
||
}
|
||
let split = split_side_by_side(&items);
|
||
assert!(
|
||
split.is_empty(),
|
||
"label+number table should not be split side-by-side"
|
||
);
|
||
}
|
||
}
|