pub(crate) mod analysis;
mod classify;
mod convert;
mod postprocess;
mod preprocess;
pub use convert::to_markdown_from_lines;
use std::collections::{HashMap, HashSet};
use crate::extractor::group_into_lines_with_thresholds;
use crate::types::{PdfLine, PdfRect, TextItem};
use analysis::calculate_font_stats_from_items;
use classify::{format_list_item, is_code_like, is_list_item};
use convert::{merge_continuation_tables, to_markdown_from_lines_with_tables_and_images};
pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
if items.len() < 40 {
return vec![];
}
let mut xs: Vec<f32> = items.iter().map(|i| i.x).collect();
xs.sort_by(|a, b| a.total_cmp(b));
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![];
}
let min_side = items.len() / 5;
let mut best_split = 0.0f32;
let mut best_crossing = usize::MAX;
for &split_x in &candidates {
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![];
}
let max_crossing = (items.len() / 20).max(2);
if best_crossing > max_crossing {
return vec![];
}
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![];
}
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;
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)]
}
fn split_from_hint_regions(items: &[TextItem], rects: &[PdfRect], page: u32) -> Vec<(f32, f32)> {
use crate::tables::{cluster_rects, RectHintRegion};
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![];
}
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![];
}
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![];
}
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;
}
}
}
}
if pair_count < 3 {
return vec![];
}
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![]
}
}
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;
let overlap = rx_max.min(x_hi) - rx_min.max(x_lo);
if overlap <= 0.0 {
return false;
}
if rw < band_width * 0.7 {
true
} else {
overlap >= rw * 0.7
}
}
})
.cloned()
.collect()
}
type BandSpec = (Vec<TextItem>, Vec<usize>, Vec<PdfRect>, Vec<PdfLine>);
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()
}
#[derive(Debug, Clone)]
pub struct MarkdownOptions {
pub detect_headers: bool,
pub detect_lists: bool,
pub detect_code: bool,
pub base_font_size: Option<f32>,
pub remove_page_numbers: bool,
pub format_urls: bool,
pub fix_hyphenation: bool,
pub detect_bold: bool,
pub detect_italic: bool,
pub include_images: bool,
pub include_links: bool,
pub include_page_numbers: bool,
pub strip_headers_footers: bool,
}
impl Default for MarkdownOptions {
fn default() -> Self {
Self {
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,
include_images: false,
include_links: true,
include_page_numbers: false,
strip_headers_footers: true,
}
}
}
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;
}
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;
}
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;
}
output.push_str(trimmed);
output.push('\n');
}
if in_code_block {
output.push_str("```\n");
}
output
}
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
to_markdown_from_items_with_rects(items, options, &[])
}
pub fn to_markdown_from_items_with_rects(
items: Vec<TextItem>,
options: MarkdownOptions,
rects: &[crate::types::PdfRect],
) -> String {
to_markdown_from_items_with_rects_and_lines(
items,
options,
rects,
&[],
&HashMap::new(),
None,
&[],
)
}
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],
page_thresholds: &HashMap<u32, f32>,
struct_roles: Option<&HashMap<u32, HashMap<i64, crate::structure_tree::StructRole>>>,
struct_tables: &[crate::structure_tree::StructTable],
) -> String {
use crate::tables::{
detect_tables, detect_tables_from_lines, detect_tables_from_rects,
detect_tables_from_struct_tree, table_to_markdown, try_build_rect_guided_table,
};
use crate::types::ItemType;
if items.is_empty() {
return String::new();
}
let mut images: Vec<TextItem> = Vec::new();
let mut links: Vec<TextItem> = Vec::new();
let mut text_items: Vec<TextItem> = Vec::new();
for item in items {
match &item.item_type {
ItemType::Image => {
if options.include_images {
images.push(item);
}
}
ItemType::Link(_) => {
if options.include_links {
links.push(item);
}
}
ItemType::Text | ItemType::FormField => {
text_items.push(item);
}
}
}
let font_stats = calculate_font_stats_from_items(&text_items);
let base_size = options
.base_font_size
.unwrap_or(font_stats.most_common_size);
let mut table_items: HashSet<usize> = HashSet::new();
let mut page_tables: HashMap<u32, Vec<(f32, String)>> = HashMap::new();
let mut page_images: HashMap<u32, Vec<(f32, String)>> = 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((img.y, img_md));
}
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));
}
let mut pages: Vec<u32> = page_groups.keys().copied().collect();
pages.sort();
let page_count = pages.last().copied().unwrap_or(0) + 1;
let mut page_band_splits: HashMap<u32, Vec<(f32, f32)>> = 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_has_columns = {
let cols = crate::extractor::detect_columns(&page_items, page, false);
cols.len() >= 2
};
let mut bands = split_side_by_side(&page_items);
if bands.is_empty() {
bands = split_from_hint_regions(&page_items, rects, page);
if !bands.is_empty() {
page_band_splits.insert(page, bands.clone());
}
}
let band_specs: Vec<BandSpec> = if bands.is_empty() {
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; 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()
};
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;
}
let mut rect_claimed: HashSet<usize> = HashSet::new();
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);
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
}
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);
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
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);
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
}
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);
}
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(&table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
for &band_idx in &inside_map {
rect_claimed.insert(band_idx);
}
}
}
}
let mut run_heuristic =
|subset_items: &[TextItem], index_map: &[usize], min_items: usize| {
if subset_items.len() < min_items {
return;
}
let tables = detect_tables(subset_items, base_size, false);
for table in tables {
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);
}
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(&table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
};
if rect_claimed.is_empty() && hint_regions.is_empty() {
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() {
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 {
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);
}
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);
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(&table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
}
}
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 {
let page_text: Vec<TextItem> = text_items
.iter()
.filter(|i| i.page == page)
.cloned()
.collect();
let line_tables = detect_tables_from_lines(&page_text, &synth_lines, page);
for table in &line_tables {
for &idx in &table.item_indices {
table_items.insert(idx);
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
}
}
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
);
let heuristic_tables = detect_tables(band_items, base_size, page_has_columns);
for table in &heuristic_tables {
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);
}
}
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
}
}
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
};
let non_table_items: Vec<TextItem> = text_items
.into_iter()
.enumerate()
.filter(|(idx, _)| !table_items.contains(idx))
.map(|(_, item)| item)
.collect();
let table_only_pages: HashSet<u32> = {
let pages_with_text: HashSet<u32> = non_table_items.iter().map(|i| i.page).collect();
page_tables
.keys()
.filter(|p| !pages_with_text.contains(p))
.copied()
.collect()
};
merge_continuation_tables(&mut page_tables, &table_only_pages);
let table_page_set: HashSet<u32> = page_tables.keys().copied().collect();
let lines = if page_band_splits.is_empty() {
group_into_lines_with_thresholds(non_table_items, page_thresholds, &table_page_set)
} else {
let mut split_page_items: HashMap<u32, Vec<TextItem>> = HashMap::new();
let mut unsplit_items: Vec<TextItem> = Vec::new();
for item in non_table_items {
if page_band_splits.contains_key(&item.page) {
split_page_items.entry(item.page).or_default().push(item);
} else {
unsplit_items.push(item);
}
}
let mut all_lines =
group_into_lines_with_thresholds(unsplit_items, page_thresholds, &table_page_set);
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(group_into_lines_with_thresholds(
band_items,
page_thresholds,
&table_page_set,
));
}
}
page_lines.sort_by(|a, b| b.y.total_cmp(&a.y));
all_lines.extend(page_lines);
}
all_lines
};
let lines = if options.strip_headers_footers {
preprocess::strip_repeated_lines(lines, page_count)
} else {
lines
};
let band_split_page_set: HashSet<u32> = page_band_splits.keys().copied().collect();
to_markdown_from_lines_with_tables_and_images(
lines,
options,
page_tables,
page_images,
&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() {
let tiers = vec![24.0, 18.0, 15.0];
assert_eq!(detect_header_level(24.0, 12.0, &tiers), Some(1));
assert_eq!(detect_header_level(18.0, 12.0, &tiers), Some(2));
assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(3));
assert_eq!(detect_header_level(12.0, 12.0, &tiers), None);
let tiers = vec![15.0];
assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(1));
assert_eq!(detect_header_level(14.0, 12.0, &tiers), None);
assert_eq!(detect_header_level(12.0, 12.0, &tiers), None);
let tiers: Vec<f32> = vec![];
assert_eq!(detect_header_level(24.0, 12.0, &tiers), Some(1));
assert_eq!(detect_header_level(18.0, 12.0, &tiers), Some(2));
assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(3));
assert_eq!(detect_header_level(14.5, 12.0, &tiers), Some(4));
assert_eq!(detect_header_level(14.0, 12.0, &tiers), None);
assert_eq!(detect_header_level(12.0, 12.0, &tiers), None);
let tiers = vec![20.0];
assert_eq!(detect_header_level(13.0, 12.0, &tiers), 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 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,
item_type: crate::types::ItemType::Text,
mcid: None,
}
}
#[test]
fn split_from_hint_regions_too_few_rects() {
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() {
let items = vec![make_item(10.0, 100.0, 1)];
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() {
let mut items = Vec::new();
for row in 0..30 {
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);
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);
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"
);
}
}