* feat(extractor): descriptor/embedded-font style flags + geometric strikeout detection
Two style-recall gaps, both invisible to the existing name-based
heuristics:
1. Subset fonts with opaque BaseFont names ("Tc1", "AAAAAB+Amplitude")
defeat is_italic_font/is_bold_font. New descriptor_style_flags reads
the FontDescriptor (ItalicAngle beyond 4 degrees, Flags bit 7 Italic,
bit 19 ForceBold) and, when the descriptor claims upright, falls back
to the embedded font file: ttf-parser's OS/2 fsSelection + post
italicAngle for sfnt fonts, and the CFF Name INDEX PostScript name
for bare-CFF FontFile3 (descriptor rewritten to ItalicAngle 0 while
embedding "Amplitude-LightItalic" was observed in the wild).
ORed into is_bold/is_italic at item creation (content streams and
form XObjects).
2. No strikeout signal existed. New is_strikeout on TextItem, detected
in the same pass as underline: same rules pipeline (stroked lines /
thin filled rects, table-ruling suppression), different vertical
window — a rule crossing the glyphs at 12-55% of the em above the
baseline instead of sitting at it. Exposed through napi and python
bindings and pdf2md --items-json.
Verified on public ParseBench corpus docs: previously-missed italic
council titles and bold CJK itinerary headings now flagged (render-
checked); 24/508 docs gain flags, none lose any; 35 strikeout items
detected corpus-wide, disjoint from underline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB
* fix(review): quote-op advance width, Ts text rise, doc-level font style cache (PR #125 review)
Address three valid findings from review:
- The ' (move-to-next-line-and-show-text) operator emitted zero-width
items and never advanced the text matrix, so geometric underline/
strikeout detection (which requires width > 0) could never mark its
text, and following show ops overlapped it. Reuse Tj's advance-width
computation and matrix advance.
- Ts (text rise) was dropped entirely: raised/lowered runs kept the
unshifted baseline, so rules drawn at the risen glyph position missed
the strike/underline windows. Track rise in the text state (saved and
restored with q/Q) and shift the rendering position through the text
matrix's y column; advances stay on the unshifted matrix per spec.
- descriptor_style_flags re-decompressed and re-parsed the same embedded
font program on every page whenever the descriptor left a style flag
unset (the common case). Add a document-scoped FontStyleCache keyed by
the FontFile2/FontFile3 object id, threaded through page and form
extraction alongside the existing CMapDecisionCache.
The fourth finding (Form XObject rules never reach geometric detection)
is real but pre-existing for underline and needs the form walker to grow
path/paint tracking plus a new return type; deferred as a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB
* fix(review): ActualText items render at their glyphs' text rise (PR #125 review)
The EMC-built ActualText item used the captured text matrix without the
rise adjustment the ordinary Tj/TJ/' emission sites apply, so a tagged
run shown with Ts landed on the unshifted baseline — off the strikeout/
underline windows and inconsistent with untagged runs. The rise is
captured together with the first-glyph matrix (and at BDC for the
entry-position fallback): the item must render at the rise of its
GLYPHS, not whatever rise is set by EMC time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB
* fix(review): capture ActualText glyph position after the quote op's line move (PR #125 review)
The `'` handler skipped the entire suppressed-extraction block, so a
tagged span whose show op is `'` never captured its glyph matrix/rise —
the EMC item fell back to the BDC-entry matrix, which sits on the
PREVIOUS line (the `'` line move happens after BDC) with no rise. The
capture now happens right after the line move, matching the Tj/TJ
paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB
* fix(review): style-boundary gate on subscript merge + strikeout suppression coverage (PR #125 review)
merge_subscript_items absorbed a script digit into its parent
regardless of underline/strikeout flags — dropping the digit's own mark
or widening the parent's over it. The merged item carries one flag, so
differing marks now break the merge, mirroring merge_text_items'
style-boundary rule (pre-existing for underline as well).
Also extends the table-suppression test to assert is_strikeout is
cleared alongside is_underline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1292 lines
49 KiB
Rust
1292 lines
49 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 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};
|
|
|
|
/// 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)]
|
|
}
|
|
|
|
/// 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.
|
|
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()
|
|
}
|
|
|
|
/// Options for markdown conversion
|
|
#[derive(Debug, Clone)]
|
|
pub struct MarkdownOptions {
|
|
/// 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 {
|
|
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
|
|
}
|
|
|
|
/// 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 {
|
|
to_markdown_from_items_with_rects_and_lines(
|
|
items,
|
|
options,
|
|
rects,
|
|
&[],
|
|
&HashMap::new(),
|
|
None,
|
|
&[],
|
|
)
|
|
}
|
|
|
|
/// 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],
|
|
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();
|
|
}
|
|
|
|
// Separate images and links from text items
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<(f32, String)>> = HashMap::new();
|
|
|
|
// Store images by page and Y position for insertion
|
|
let mut page_images: HashMap<u32, Vec<(f32, String)>> = HashMap::new();
|
|
|
|
for img in &images {
|
|
// Extract image name from "[Image: Im0]" format
|
|
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));
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
let mut pages: Vec<u32> = page_groups.keys().copied().collect();
|
|
pages.sort();
|
|
let page_count = pages.last().copied().unwrap_or(0) + 1;
|
|
|
|
// 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();
|
|
|
|
for page in pages {
|
|
let group = page_groups.get(&page).unwrap();
|
|
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
|
|
|
// Detect columns early — on multi-column pages, the merged-band retry
|
|
// should skip body-font heuristic table detection (which mistakes column
|
|
// text for tables). Individual band heuristic detection is left enabled
|
|
// because bands are scoped to single columns.
|
|
let page_has_columns = {
|
|
let cols = crate::extractor::detect_columns(&page_items, page, false);
|
|
cols.len() >= 2
|
|
};
|
|
|
|
// Check for side-by-side layout (e.g. two tables placed left and right)
|
|
let mut bands = split_side_by_side(&page_items);
|
|
// 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());
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
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));
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
);
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<TextItem> = text_items
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter(|(idx, _)| !table_items.contains(idx))
|
|
.map(|(_, item)| item)
|
|
.collect();
|
|
|
|
// Find pages that are table-only (no remaining non-table text)
|
|
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 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();
|
|
|
|
// 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() {
|
|
group_into_lines_with_thresholds(non_table_items, page_thresholds, &table_page_set)
|
|
} else {
|
|
// Separate items into band-split pages and non-split pages
|
|
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);
|
|
}
|
|
}
|
|
// Process unsplit pages normally
|
|
let mut all_lines =
|
|
group_into_lines_with_thresholds(unsplit_items, page_thresholds, &table_page_set);
|
|
// 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(group_into_lines_with_thresholds(
|
|
band_items,
|
|
page_thresholds,
|
|
&table_page_set,
|
|
));
|
|
}
|
|
}
|
|
// 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);
|
|
}
|
|
all_lines
|
|
};
|
|
|
|
// Strip repeated headers/footers before conversion
|
|
let lines = if options.strip_headers_footers {
|
|
preprocess::strip_repeated_lines(lines, page_count)
|
|
} else {
|
|
lines
|
|
};
|
|
|
|
// Convert to markdown, inserting tables and images at appropriate positions
|
|
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() {
|
|
// 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), 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);
|
|
|
|
// Single tier: 15→H1 (ratio 1.25 ≥ 1.2), 14→None (ratio 1.17 < 1.2)
|
|
let tiers = vec![15.0];
|
|
assert_eq!(detect_header_level(15.0, 12.0, &tiers), Some(1));
|
|
assert_eq!(detect_header_level(14.0, 12.0, &tiers), None);
|
|
assert_eq!(detect_header_level(12.0, 12.0, &tiers), None);
|
|
|
|
// No tiers (empty): falls back to ratio thresholds
|
|
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);
|
|
|
|
// 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), 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,
|
|
is_underline: false,
|
|
is_strikeout: false,
|
|
item_type: crate::types::ItemType::Text,
|
|
mcid: None,
|
|
}
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|