Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd374f68e9 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@firecrawl/pdf-inspector",
|
||||
"version": "1.8.3",
|
||||
"version": "1.4.0",
|
||||
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
|
||||
+3
-239
@@ -99,13 +99,6 @@ pub struct PageRegionTexts {
|
||||
pub regions: Vec<RegionText>,
|
||||
}
|
||||
|
||||
/// Vector-grid detection result compatible with `extractTablesWithStructure*`.
|
||||
#[napi(object)]
|
||||
pub struct VectorGridDetectionJs {
|
||||
pub structure_tokens: Vec<String>,
|
||||
pub cell_bboxes: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -324,236 +317,6 @@ pub fn extract_tables_in_regions(
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect a vector ruled-line / rectangle grid inside one page region.
|
||||
///
|
||||
/// Returns TSR-compatible structure tokens plus crop-pixel cell bboxes, or
|
||||
/// `null` when the region does not contain a valid vector grid.
|
||||
///
|
||||
/// `pageIdx` is 0-indexed. `regionPdfPtBbox` is `[x1,y1,x2,y2]` in PDF
|
||||
/// points with top-left origin. `renderDpi` is the DPI of the crop image that
|
||||
/// will consume the returned cell bboxes.
|
||||
#[napi]
|
||||
pub fn detect_vector_grid_in_region(
|
||||
buffer: Buffer,
|
||||
page_idx: u32,
|
||||
region_pdf_pt_bbox: Vec<f64>,
|
||||
render_dpi: f64,
|
||||
) -> Result<Option<VectorGridDetectionJs>> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
let region = if region_pdf_pt_bbox.len() == 4 {
|
||||
[
|
||||
region_pdf_pt_bbox[0] as f32,
|
||||
region_pdf_pt_bbox[1] as f32,
|
||||
region_pdf_pt_bbox[2] as f32,
|
||||
region_pdf_pt_bbox[3] as f32,
|
||||
]
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
};
|
||||
|
||||
catch_panic("detect_vector_grid_in_region", move || {
|
||||
let result = pdf_inspector::detect_vector_grid_in_region_mem(
|
||||
&bytes,
|
||||
page_idx,
|
||||
region,
|
||||
render_dpi as f32,
|
||||
)
|
||||
.map_err(|e| to_napi_err(e, "detect_vector_grid_in_region"))?;
|
||||
|
||||
Ok(result.map(|r| VectorGridDetectionJs {
|
||||
structure_tokens: r.structure_tokens,
|
||||
cell_bboxes: r
|
||||
.cell_bboxes
|
||||
.into_iter()
|
||||
.map(|bbox| bbox.into_iter().map(|v| v as f64).collect())
|
||||
.collect(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// One cropped table region plus its raw structure-recovery output, for
|
||||
/// `extractTablesWithStructure`.
|
||||
///
|
||||
/// `structureTokens` and `cellBboxes` are typically produced by an external
|
||||
/// table-structure recognition model (e.g. SLANet on PaddleOCR) running on
|
||||
/// a rendered crop of the page. pdf-inspector uses the structure to lay out
|
||||
/// the cells and pulls the cell text from the native PDF — no OCR involved.
|
||||
#[napi(object)]
|
||||
pub struct TsrTableInputJs {
|
||||
/// 0-indexed page number where the crop was taken from.
|
||||
pub page: u32,
|
||||
/// Crop bbox on the page, `[x1, y1, x2, y2]` in PDF points with
|
||||
/// top-left origin.
|
||||
pub crop_pdf_pt_bbox: Vec<f64>,
|
||||
/// DPI the crop image was rendered at (e.g. `200.0`).
|
||||
pub render_dpi: f64,
|
||||
/// Raw structure tokens emitted by the TSR model, in document order.
|
||||
pub structure_tokens: Vec<String>,
|
||||
/// One bbox per cell (in document order). May be 4-element
|
||||
/// `[x1,y1,x2,y2]` or 8-element 4-corner polygon, in crop image-pixel
|
||||
/// space.
|
||||
pub cell_bboxes: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Extract markdown tables using externally-supplied structure recovery.
|
||||
///
|
||||
/// For each input, pairs structure tokens with cell bboxes (rowspan/colspan
|
||||
/// aware), converts each cell bbox from crop image-pixels into page PDF
|
||||
/// points, pulls the cell's text from the native PDF, and emits a markdown
|
||||
/// pipe-table.
|
||||
///
|
||||
/// Returns one markdown string per input, in input order.
|
||||
#[napi]
|
||||
pub fn extract_tables_with_structure(
|
||||
buffer: Buffer,
|
||||
inputs: Vec<TsrTableInputJs>,
|
||||
) -> Result<Vec<String>> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
let parsed = parse_tsr_inputs(&inputs);
|
||||
|
||||
catch_panic("extract_tables_with_structure", move || {
|
||||
pdf_inspector::extract_tables_with_structure_mem(&bytes, &parsed)
|
||||
.map_err(|e| to_napi_err(e, "extract_tables_with_structure"))
|
||||
})
|
||||
}
|
||||
|
||||
/// One resolved cell from `extractTablesWithStructureCells`.
|
||||
#[napi(object)]
|
||||
pub struct StructuredCellJs {
|
||||
/// 0-indexed grid row.
|
||||
pub row: u32,
|
||||
/// 0-indexed grid column.
|
||||
pub col: u32,
|
||||
/// 1 for a normal cell.
|
||||
pub rowspan: u32,
|
||||
/// 1 for a normal cell.
|
||||
pub colspan: u32,
|
||||
/// `true` when the cell is a `<th>` or sits inside `<thead>`.
|
||||
pub is_header: bool,
|
||||
/// Text extracted from the native PDF for this cell (may be empty).
|
||||
pub text: String,
|
||||
/// Axis-aligned bbox `[x1, y1, x2, y2]` in page PDF-points, top-left
|
||||
/// origin. Useful for debug overlays or per-cell post-processing.
|
||||
pub page_pt_bbox: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Extract structured cells using externally-supplied structure recovery.
|
||||
///
|
||||
/// Lower-level sibling of [`extractTablesWithStructure`]: instead of
|
||||
/// rendering markdown, returns the resolved cells (row, col, rowspan,
|
||||
/// colspan, isHeader, text, pagePtBbox) so callers can drive their own
|
||||
/// rendering, debug overlays, or per-cell post-processing.
|
||||
///
|
||||
/// Returns one `Array<StructuredCellJs>` per input, in input order.
|
||||
#[napi]
|
||||
pub fn extract_tables_with_structure_cells(
|
||||
buffer: Buffer,
|
||||
inputs: Vec<TsrTableInputJs>,
|
||||
) -> Result<Vec<Vec<StructuredCellJs>>> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
let parsed = parse_tsr_inputs(&inputs);
|
||||
|
||||
catch_panic("extract_tables_with_structure_cells", move || {
|
||||
let result = pdf_inspector::extract_tables_with_structure_cells_mem(&bytes, &parsed)
|
||||
.map_err(|e| to_napi_err(e, "extract_tables_with_structure_cells"))?;
|
||||
Ok(result
|
||||
.into_iter()
|
||||
.map(|cells| {
|
||||
cells
|
||||
.into_iter()
|
||||
.map(|c| StructuredCellJs {
|
||||
row: c.row as u32,
|
||||
col: c.col as u32,
|
||||
rowspan: c.rowspan as u32,
|
||||
colspan: c.colspan as u32,
|
||||
is_header: c.is_header,
|
||||
text: c.text,
|
||||
page_pt_bbox: c.page_pt_bbox.iter().map(|v| *v as f64).collect(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// One result from `extractTablesWithStructureAuto` — markdown plus a
|
||||
/// diagnostic flag identifying which path produced it.
|
||||
///
|
||||
/// `fallbackReason` is `null` when the TSR-hybrid path produced the
|
||||
/// markdown directly. When stage 1's quality check fires (the cells
|
||||
/// look like a SLANet detection pathology — phantom rows or multi-row
|
||||
/// content in a single cell), the auto path may expand the TSR cells
|
||||
/// in-place or run the heuristic table extractor on the same region.
|
||||
/// `fallbackReason` carries the diagnostic label (for example
|
||||
/// `"multi_row_in_cell_expanded"` or `"phantom_empty_row"`).
|
||||
#[napi(object)]
|
||||
pub struct TableExtractionResultJs {
|
||||
pub markdown: String,
|
||||
pub fallback_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto-fallback variant of [`extractTablesWithStructure`].
|
||||
///
|
||||
/// Runs the TSR-hybrid path, checks the resulting cells for known
|
||||
/// SLANet detection pathologies, expands multi-row cells in-place when
|
||||
/// possible, and otherwise falls back to the heuristic
|
||||
/// `extractTablesInRegions` for inputs where the TSR path looks
|
||||
/// compromised.
|
||||
///
|
||||
/// On clean inputs this returns identical markdown to
|
||||
/// `extractTablesWithStructure`; on flagged inputs `fallbackReason` is
|
||||
/// set to the recovery path that produced the result.
|
||||
#[napi]
|
||||
pub fn extract_tables_with_structure_auto(
|
||||
buffer: Buffer,
|
||||
inputs: Vec<TsrTableInputJs>,
|
||||
) -> Result<Vec<TableExtractionResultJs>> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
let parsed = parse_tsr_inputs(&inputs);
|
||||
|
||||
catch_panic("extract_tables_with_structure_auto", move || {
|
||||
let result = pdf_inspector::extract_tables_with_structure_auto_mem(&bytes, &parsed)
|
||||
.map_err(|e| to_napi_err(e, "extract_tables_with_structure_auto"))?;
|
||||
Ok(result
|
||||
.into_iter()
|
||||
.map(|r| TableExtractionResultJs {
|
||||
markdown: r.markdown,
|
||||
fallback_reason: r.fallback_reason,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_tsr_inputs(inputs: &[TsrTableInputJs]) -> Vec<pdf_inspector::TsrTableInput> {
|
||||
inputs
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let crop = if i.crop_pdf_pt_bbox.len() == 4 {
|
||||
[
|
||||
i.crop_pdf_pt_bbox[0] as f32,
|
||||
i.crop_pdf_pt_bbox[1] as f32,
|
||||
i.crop_pdf_pt_bbox[2] as f32,
|
||||
i.crop_pdf_pt_bbox[3] as f32,
|
||||
]
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
};
|
||||
let cell_bboxes: Vec<Vec<f32>> = i
|
||||
.cell_bboxes
|
||||
.iter()
|
||||
.map(|bb| bb.iter().map(|v| *v as f32).collect())
|
||||
.collect();
|
||||
pdf_inspector::TsrTableInput {
|
||||
page: i.page,
|
||||
crop_pdf_pt_bbox: crop,
|
||||
render_dpi: i.render_dpi as f32,
|
||||
structure_tokens: i.structure_tokens.clone(),
|
||||
cell_bboxes,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-page markdown extraction result.
|
||||
#[napi(object)]
|
||||
pub struct PageMarkdownResult {
|
||||
@@ -597,8 +360,9 @@ pub fn extract_pages_markdown(
|
||||
) -> Result<PagesExtractionResult> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("extract_pages_markdown", move || {
|
||||
let result = pdf_inspector::extract_pages_markdown_mem(&bytes, pages.as_deref())
|
||||
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
|
||||
let result =
|
||||
pdf_inspector::extract_pages_markdown_mem(&bytes, pages.as_deref())
|
||||
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
|
||||
Ok(PagesExtractionResult {
|
||||
pages: result
|
||||
.pages
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
extractText,
|
||||
extractTextWithPositions,
|
||||
extractTextInRegions,
|
||||
detectVectorGridInRegion,
|
||||
extractPagesMarkdown,
|
||||
} from './index.js';
|
||||
|
||||
@@ -91,17 +90,6 @@ assert.equal(typeof regionResults[0].regions[0].text, 'string');
|
||||
assert.equal(typeof regionResults[0].regions[0].needsOcr, 'boolean');
|
||||
console.log(' extractTextInRegions: OK');
|
||||
|
||||
// --- detectVectorGridInRegion ---
|
||||
console.log('Testing detectVectorGridInRegion...');
|
||||
const vectorGrid = detectVectorGridInRegion(fixture, 0, [0, 0, 600, 800], 72);
|
||||
assert.ok(vectorGrid === null || typeof vectorGrid === 'object');
|
||||
if (vectorGrid) {
|
||||
assert.ok(Array.isArray(vectorGrid.structureTokens));
|
||||
assert.ok(Array.isArray(vectorGrid.cellBboxes));
|
||||
assert.ok(vectorGrid.cellBboxes.every(bbox => Array.isArray(bbox) && bbox.length === 4));
|
||||
}
|
||||
console.log(' detectVectorGridInRegion: OK');
|
||||
|
||||
// --- extractPagesMarkdown ---
|
||||
console.log('Testing extractPagesMarkdown...');
|
||||
|
||||
|
||||
+11
-33
@@ -1,12 +1,8 @@
|
||||
//! CLI tool for detecting PDF type (text-based vs scanned)
|
||||
|
||||
use pdf_inspector::{
|
||||
detect_pdf_type, detector::estimate_page_count_from_bytes, process_pdf_with_options,
|
||||
PdfOptions, PdfType, ProcessMode,
|
||||
};
|
||||
use pdf_inspector::{detect_pdf_type, process_pdf_with_options, PdfOptions, PdfType, ProcessMode};
|
||||
use std::env;
|
||||
use std::fmt::Write;
|
||||
use std::fs;
|
||||
use std::process;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -68,32 +64,6 @@ fn pdf_type_str(pdf_type: &PdfType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn page_count_hint(pdf_path: &str) -> Option<u32> {
|
||||
fs::read(pdf_path)
|
||||
.ok()
|
||||
.map(|bytes| estimate_page_count_from_bytes(&bytes))
|
||||
.filter(|&count| count > 0)
|
||||
}
|
||||
|
||||
fn print_error(e: &pdf_inspector::PdfError, pdf_path: &str, json_output: bool) {
|
||||
if json_output {
|
||||
if let Some(count) = page_count_hint(pdf_path) {
|
||||
println!(
|
||||
r#"{{"error":"{}","page_count_hint":{}}}"#,
|
||||
json_escape(&e.to_string()),
|
||||
count
|
||||
);
|
||||
} else {
|
||||
println!(r#"{{"error":"{}"}}"#, json_escape(&e.to_string()));
|
||||
}
|
||||
} else {
|
||||
eprintln!("Error: {}", e);
|
||||
if let Some(count) = page_count_hint(pdf_path) {
|
||||
eprintln!("Page count hint: {}", count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
match process_pdf_with_options(pdf_path, PdfOptions::new().mode(ProcessMode::Analyze)) {
|
||||
Ok(result) => {
|
||||
@@ -165,7 +135,11 @@ fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
print_error(&e, pdf_path, json_output);
|
||||
if json_output {
|
||||
println!(r#"{{"error":"{}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -262,7 +236,11 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
print_error(&e, pdf_path, json_output);
|
||||
if json_output {
|
||||
println!(r#"{{"error":"{}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-57
@@ -97,9 +97,26 @@ pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
|
||||
) -> Result<PdfTypeResult, PdfError> {
|
||||
crate::validate_pdf_file(&path)?;
|
||||
|
||||
let (doc, page_count) = crate::load_document_from_path(&path)?;
|
||||
// First, load metadata only (fast operation)
|
||||
let metadata = match Document::load_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_metadata_with_password(&path, "")?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
detect_from_document(&doc, page_count, &config)
|
||||
// Then load the full document for content inspection
|
||||
// We use filtered loading to skip heavy objects we don't need
|
||||
let doc = match Document::load(&path) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_with_password(&path, "")?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
detect_from_document(&doc, metadata.page_count, &config)
|
||||
}
|
||||
|
||||
/// Detect PDF type from memory buffer
|
||||
@@ -114,64 +131,25 @@ pub fn detect_pdf_type_mem_with_config(
|
||||
) -> Result<PdfTypeResult, PdfError> {
|
||||
crate::validate_pdf_bytes(buffer)?;
|
||||
|
||||
let (doc, page_count) = crate::load_document_from_mem(buffer)?;
|
||||
|
||||
detect_from_document(&doc, page_count, &config)
|
||||
}
|
||||
|
||||
/// Heuristic page-count fallback for malformed PDFs that cannot be parsed.
|
||||
///
|
||||
/// This scans raw bytes for page dictionaries (`/Type /Page`) while excluding
|
||||
/// the page tree node (`/Type /Pages`). It is intended as a low-confidence hint
|
||||
/// for diagnostics; parsed page-tree counts remain authoritative.
|
||||
pub fn estimate_page_count_from_bytes(buffer: &[u8]) -> u32 {
|
||||
let mut count = 0u32;
|
||||
let mut pos = 0usize;
|
||||
|
||||
while let Some(rel_idx) = find_bytes(&buffer[pos..], b"/Type") {
|
||||
let mut value_pos = pos + rel_idx + b"/Type".len();
|
||||
value_pos = skip_pdf_whitespace(buffer, value_pos);
|
||||
|
||||
if buffer.get(value_pos) == Some(&b'/') {
|
||||
let name_start = value_pos + 1;
|
||||
let name_end = name_start + b"Page".len();
|
||||
if name_end <= buffer.len()
|
||||
&& &buffer[name_start..name_end] == b"Page"
|
||||
&& buffer
|
||||
.get(name_end)
|
||||
.is_none_or(|b| is_pdf_name_delimiter(*b))
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
// Load metadata first (fast)
|
||||
let metadata = match Document::load_metadata_mem(buffer) {
|
||||
Ok(m) => m,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_metadata_mem_with_password(buffer, "")?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
pos += rel_idx + b"/Type".len();
|
||||
}
|
||||
// Load document for inspection
|
||||
let doc = match Document::load_mem(buffer) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_mem_with_options(buffer, lopdf::LoadOptions::with_password(""))?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn skip_pdf_whitespace(buffer: &[u8], mut pos: usize) -> usize {
|
||||
while pos < buffer.len() && is_pdf_whitespace(buffer[pos]) {
|
||||
pos += 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
fn is_pdf_whitespace(byte: u8) -> bool {
|
||||
matches!(byte, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
|
||||
}
|
||||
|
||||
fn is_pdf_name_delimiter(byte: u8) -> bool {
|
||||
is_pdf_whitespace(byte)
|
||||
|| matches!(
|
||||
byte,
|
||||
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
|
||||
)
|
||||
detect_from_document(&doc, metadata.page_count, &config)
|
||||
}
|
||||
|
||||
/// Detection logic on a pre-loaded document.
|
||||
|
||||
+28
-4
@@ -36,14 +36,26 @@ pub(crate) use layout::ColumnRegion;
|
||||
/// Extract text from PDF file as plain string
|
||||
pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
|
||||
crate::validate_pdf_file(&path)?;
|
||||
let (doc, _) = crate::load_document_from_path(&path)?;
|
||||
let doc = match Document::load(&path) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_with_password(&path, "")?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
extract_text_from_doc(&doc)
|
||||
}
|
||||
|
||||
/// Extract text from PDF memory buffer
|
||||
pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
|
||||
crate::validate_pdf_bytes(buffer)?;
|
||||
let (doc, _) = crate::load_document_from_mem(buffer)?;
|
||||
let doc = match Document::load_mem(buffer) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_mem_with_options(buffer, lopdf::LoadOptions::with_password(""))?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
extract_text_from_doc(&doc)
|
||||
}
|
||||
|
||||
@@ -79,7 +91,13 @@ pub(crate) fn extract_text_with_positions_and_rects<P: AsRef<Path>>(
|
||||
page_filter: Option<&HashSet<u32>>,
|
||||
) -> Result<PageExtraction, PdfError> {
|
||||
crate::validate_pdf_file(&path)?;
|
||||
let (doc, _) = crate::load_document_from_path(&path)?;
|
||||
let doc = match Document::load(&path) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_with_password(&path, "")?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let (extraction, _thresholds, _gid_pages) =
|
||||
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)?;
|
||||
@@ -106,7 +124,13 @@ pub(crate) fn extract_text_with_positions_mem_and_rects(
|
||||
page_filter: Option<&HashSet<u32>>,
|
||||
) -> Result<PageExtraction, PdfError> {
|
||||
crate::validate_pdf_bytes(buffer)?;
|
||||
let (doc, _) = crate::load_document_from_mem(buffer)?;
|
||||
let doc = match Document::load_mem(buffer) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
|
||||
Document::load_mem_with_options(buffer, lopdf::LoadOptions::with_password(""))?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let (extraction, _thresholds, _gid_pages) =
|
||||
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)?;
|
||||
|
||||
+8
-2362
File diff suppressed because it is too large
Load Diff
+331
-3
@@ -14,15 +14,243 @@ mod preprocess;
|
||||
|
||||
pub use convert::to_markdown_from_lines;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::extractor::group_into_lines_with_thresholds;
|
||||
use crate::tables::Table;
|
||||
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};
|
||||
|
||||
/// Promote unclaimed header items sitting directly above a struct-tree table
|
||||
/// into the table's first row.
|
||||
///
|
||||
/// Some tagged PDFs mark only the data rows under `<Table>` / `<TR>` / `<TD>`,
|
||||
/// leaving the visible column headers above the grid untagged. Those headers
|
||||
/// then slip through to the heuristic fallback and reassemble into a spurious
|
||||
/// mini-table next to the real one. Instead, gather unclaimed items within a
|
||||
/// small vertical gap above the detected table's top and — if they align to
|
||||
/// the existing column centers — fold them into the table as the header row.
|
||||
fn promote_implicit_header_rows(mut table: Table, band_items: &[TextItem]) -> Table {
|
||||
if table.cells.is_empty() || table.columns.is_empty() || table.item_indices.is_empty() {
|
||||
return table;
|
||||
}
|
||||
|
||||
let claimed: HashSet<usize> = table.item_indices.iter().copied().collect();
|
||||
let mut x_min = f32::INFINITY;
|
||||
let mut x_max = f32::NEG_INFINITY;
|
||||
let mut y_top = f32::NEG_INFINITY;
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(item) = band_items.get(idx) {
|
||||
x_min = x_min.min(item.x);
|
||||
x_max = x_max.max(item.x + item.width);
|
||||
y_top = y_top.max(item.y + item.height);
|
||||
}
|
||||
}
|
||||
if !y_top.is_finite() || !x_min.is_finite() {
|
||||
return table;
|
||||
}
|
||||
|
||||
let num_cols = table.columns.len();
|
||||
let table_width = (x_max - x_min).max(1.0);
|
||||
// Require each header item to sit within half a column's nominal width
|
||||
// of a struct-tree column center. Any farther and we cannot be
|
||||
// confident which column the text belongs to — e.g. a standalone
|
||||
// section label above the table would otherwise be force-fit into
|
||||
// the nearest column.
|
||||
let col_tolerance = (table_width / num_cols as f32) * 0.5;
|
||||
let max_gap = 60.0;
|
||||
|
||||
// Candidate header items: unclaimed, directly above table, within x span.
|
||||
let mut candidates: Vec<(usize, &TextItem)> = band_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, item)| {
|
||||
if claimed.contains(i) {
|
||||
return false;
|
||||
}
|
||||
let above = item.y >= y_top - 2.0 && item.y < y_top + max_gap;
|
||||
let within_x = item.x + item.width >= x_min - 5.0 && item.x <= x_max + 5.0;
|
||||
above && within_x
|
||||
})
|
||||
.collect();
|
||||
|
||||
if candidates.len() < 2 {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Group candidates into visual rows by Y (bucket within ~3pt).
|
||||
candidates.sort_by(|a, b| b.1.y.total_cmp(&a.1.y)); // top-to-bottom (descending Y)
|
||||
let mut rows_by_y: Vec<Vec<(usize, &TextItem)>> = Vec::new();
|
||||
let row_bucket = 3.0;
|
||||
for (idx, item) in candidates {
|
||||
if let Some(last) = rows_by_y.last_mut() {
|
||||
if let Some((_, ref_item)) = last.first() {
|
||||
if (ref_item.y - item.y).abs() <= row_bucket {
|
||||
last.push((idx, item));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
rows_by_y.push(vec![(idx, item)]);
|
||||
}
|
||||
|
||||
// Assign each item in each row to its nearest column. Skip items that are
|
||||
// too far from any column center — they are likely unrelated captions,
|
||||
// page titles, or body text that happens to sit near the table.
|
||||
let mut header_rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut header_indices: Vec<usize> = Vec::new();
|
||||
for row in &rows_by_y {
|
||||
let mut cells_by_col: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
|
||||
let mut row_indices: Vec<usize> = Vec::new();
|
||||
let mut dropped = 0usize;
|
||||
for (idx, item) in row {
|
||||
let text = item.text.trim();
|
||||
// Skip stray punctuation or otherwise content-free fragments —
|
||||
// a lone "." or "-" left over from the prior paragraph should
|
||||
// never be promoted into a header cell.
|
||||
if text.is_empty() || !text.chars().any(|c| c.is_alphanumeric()) {
|
||||
continue;
|
||||
}
|
||||
// The struct-tree column positions are left edges (min X of the
|
||||
// column's cell items), so compare against the header item's
|
||||
// left edge too. Using the item center would penalise long
|
||||
// wrapped phrases even when they start exactly at a column.
|
||||
let cx = item.x;
|
||||
let (col, dist) = table
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(c, &cv)| (c, (cv - cx).abs()))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.expect("columns is non-empty");
|
||||
if dist > col_tolerance {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
cells_by_col.entry(col).or_default().push(text);
|
||||
row_indices.push(*idx);
|
||||
}
|
||||
// Drop rows that have no aligned cells or where most items are outliers.
|
||||
if cells_by_col.is_empty() || dropped > row_indices.len() {
|
||||
continue;
|
||||
}
|
||||
let mut row_cells = vec![String::new(); num_cols];
|
||||
for (col, parts) in cells_by_col {
|
||||
let joined: String = parts
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
row_cells[col] = joined;
|
||||
}
|
||||
header_rows.push(row_cells);
|
||||
header_indices.extend(row_indices);
|
||||
}
|
||||
|
||||
if header_rows.is_empty() {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Coalesce the discovered header rows into a single header row: each
|
||||
// column's text is concatenated top-to-bottom across the wrapped lines.
|
||||
// A real table header occupies one logical row even when visually wrapped.
|
||||
let mut merged = vec![String::new(); num_cols];
|
||||
for row in &header_rows {
|
||||
for (c, cell) in row.iter().enumerate() {
|
||||
if cell.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !merged[c].is_empty() {
|
||||
merged[c].push(' ');
|
||||
}
|
||||
merged[c].push_str(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Require ≥ 75% of columns to be filled before accepting the header.
|
||||
// Real column headers label most or all columns; stray page headers,
|
||||
// dates, and captions typically land on only one or two columns, so a
|
||||
// lower threshold lets a single mis-aligned item drag in unrelated
|
||||
// text as a first row.
|
||||
let filled_cols = merged.iter().filter(|c| !c.trim().is_empty()).count();
|
||||
if filled_cols < 2 || filled_cols * 4 < num_cols * 3 {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Reject when any cell looks like a caption marker ("Table 1",
|
||||
// "Appendix Table A2", "Figure 2", "Chart 3"). These are labels sitting
|
||||
// above the grid, not column headers — folding them into the first row
|
||||
// moves a caption out of the document flow and into the table itself.
|
||||
let looks_like_caption = merged.iter().any(|cell| {
|
||||
let t = cell.trim();
|
||||
if classify::is_caption_line(t) {
|
||||
return true;
|
||||
}
|
||||
// "Appendix Table N" / "Appendix Figure N" share the same semantics
|
||||
// but have an extra word prefix, so handle them explicitly.
|
||||
let lower = t.to_ascii_lowercase();
|
||||
if let Some(rest) = lower.strip_prefix("appendix ") {
|
||||
let rest = rest.trim_start();
|
||||
if rest.starts_with("table ")
|
||||
|| rest.starts_with("figure ")
|
||||
|| rest.starts_with("chart ")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
if looks_like_caption {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Reject when the merged "header" looks like prose or stray page
|
||||
// elements: a cell that starts mid-sentence (lowercase), runs long
|
||||
// (>12 words), or is suspiciously short (<3 chars, e.g. a stray "."
|
||||
// left behind after a sentence). Real headers are short labels.
|
||||
let looks_like_non_header = merged.iter().any(|cell| {
|
||||
let t = cell.trim();
|
||||
if t.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if t.len() < 3 {
|
||||
return true;
|
||||
}
|
||||
if t.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
|
||||
return true;
|
||||
}
|
||||
t.split_whitespace().count() > 12
|
||||
});
|
||||
if looks_like_non_header {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Reject when two filled cells hold identical text. Real column headers
|
||||
// label distinct columns, so duplicates are a sign we grabbed a page
|
||||
// title or event banner whose words happen to align across columns.
|
||||
let mut filled: Vec<&str> = merged
|
||||
.iter()
|
||||
.map(|c| c.trim())
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
filled.sort();
|
||||
if filled.windows(2).any(|w| w[0] == w[1]) {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Prepend the header row. rows/columns are centers — add an approximate
|
||||
// Y above the current top row so downstream ordering stays correct.
|
||||
table.cells.insert(0, merged);
|
||||
let approx_row_height = 14.0;
|
||||
let first_row_y = table.rows.first().copied().unwrap_or(y_top);
|
||||
table.rows.insert(0, first_row_y + approx_row_height);
|
||||
table.item_indices.extend(header_indices);
|
||||
table
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -699,11 +927,12 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
// 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 {
|
||||
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;
|
||||
}
|
||||
let table = promote_implicit_header_rows(table, band_items);
|
||||
for &idx in &table.item_indices {
|
||||
rect_claimed.insert(idx);
|
||||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||||
@@ -713,7 +942,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
}
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(table);
|
||||
let table_md = table_to_markdown(&table);
|
||||
page_tables
|
||||
.entry(page)
|
||||
.or_default()
|
||||
@@ -1246,6 +1475,105 @@ mod tests {
|
||||
assert!(split_from_hint_regions(&items, &rects, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_implicit_header_rows_merges_wrapped_header() {
|
||||
// Struct-tree tagging often covers only the data rows and leaves the
|
||||
// column-header row above the grid untagged. Simulate a 3-row 3-col
|
||||
// data-only table with 3 wrapped header items above each column,
|
||||
// aligned to the column left-edges. After promotion, the cells grid
|
||||
// must carry a header row that concatenates each column's wrapped
|
||||
// lines.
|
||||
let make_item_at = |x: f32, y: f32, text: &str| {
|
||||
let mut it = make_item(x, y, 1);
|
||||
it.text = text.to_string();
|
||||
it.width = 60.0;
|
||||
it
|
||||
};
|
||||
|
||||
let mut items = vec![
|
||||
// Data row 1 at y=200
|
||||
make_item_at(100.0, 200.0, "r1c1"),
|
||||
make_item_at(250.0, 200.0, "r1c2"),
|
||||
make_item_at(400.0, 200.0, "r1c3"),
|
||||
// Data row 2 at y=180
|
||||
make_item_at(100.0, 180.0, "r2c1"),
|
||||
make_item_at(250.0, 180.0, "r2c2"),
|
||||
make_item_at(400.0, 180.0, "r2c3"),
|
||||
// Data row 3 at y=160
|
||||
make_item_at(100.0, 160.0, "r3c1"),
|
||||
make_item_at(250.0, 160.0, "r3c2"),
|
||||
make_item_at(400.0, 160.0, "r3c3"),
|
||||
];
|
||||
// Unclaimed header items wrapped onto two visual lines just above
|
||||
// the data grid.
|
||||
items.push(make_item_at(100.0, 240.0, "First"));
|
||||
items.push(make_item_at(250.0, 240.0, "Second"));
|
||||
items.push(make_item_at(400.0, 240.0, "Third"));
|
||||
items.push(make_item_at(100.0, 225.0, "column"));
|
||||
items.push(make_item_at(250.0, 225.0, "column"));
|
||||
items.push(make_item_at(400.0, 225.0, "column"));
|
||||
|
||||
let data_indices: Vec<usize> = (0..9).collect();
|
||||
let cells = vec![
|
||||
vec!["r1c1".into(), "r1c2".into(), "r1c3".into()],
|
||||
vec!["r2c1".into(), "r2c2".into(), "r2c3".into()],
|
||||
vec!["r3c1".into(), "r3c2".into(), "r3c3".into()],
|
||||
];
|
||||
let columns = vec![100.0, 250.0, 400.0];
|
||||
let rows = vec![200.0, 180.0, 160.0];
|
||||
let table = Table::new(columns, rows, cells, data_indices);
|
||||
|
||||
let promoted = promote_implicit_header_rows(table, &items);
|
||||
assert_eq!(promoted.cells.len(), 4, "header row should be prepended");
|
||||
assert_eq!(
|
||||
promoted.cells[0],
|
||||
vec![
|
||||
"First column".to_string(),
|
||||
"Second column".to_string(),
|
||||
"Third column".to_string(),
|
||||
],
|
||||
"wrapped headers should merge per-column"
|
||||
);
|
||||
assert_eq!(promoted.cells[1], vec!["r1c1", "r1c2", "r1c3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_implicit_header_rows_ignores_faraway_items() {
|
||||
// A standalone section title sitting above the table with no column
|
||||
// alignment must not be folded in as a (single-cell) header row.
|
||||
let make_item_at = |x: f32, y: f32, text: &str| {
|
||||
let mut it = make_item(x, y, 1);
|
||||
it.text = text.to_string();
|
||||
it.width = 60.0;
|
||||
it
|
||||
};
|
||||
|
||||
let mut items = vec![
|
||||
make_item_at(100.0, 200.0, "r1c1"),
|
||||
make_item_at(250.0, 200.0, "r1c2"),
|
||||
make_item_at(100.0, 180.0, "r2c1"),
|
||||
make_item_at(250.0, 180.0, "r2c2"),
|
||||
];
|
||||
// Title far above the table and only in one x position — not a header.
|
||||
items.push(make_item_at(50.0, 240.0, "Unrelated section title"));
|
||||
|
||||
let data_indices: Vec<usize> = (0..4).collect();
|
||||
let cells = vec![
|
||||
vec!["r1c1".into(), "r1c2".into()],
|
||||
vec!["r2c1".into(), "r2c2".into()],
|
||||
];
|
||||
let columns = vec![100.0, 250.0];
|
||||
let rows = vec![200.0, 180.0];
|
||||
let table = Table::new(columns, rows, cells, data_indices);
|
||||
|
||||
let promoted = promote_implicit_header_rows(table, &items);
|
||||
assert_eq!(
|
||||
promoted.cells.len(),
|
||||
2,
|
||||
"no header promotion when only one item aligns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_split_label_plus_number_table() {
|
||||
// Balance sheet layout: text labels on left, numbers on right.
|
||||
|
||||
+15
-59
@@ -1587,69 +1587,25 @@ fn detect_row_stripe_table_from_cell_rects(
|
||||
return None;
|
||||
}
|
||||
|
||||
// Derive columns from text X-position clustering, but prefer rect
|
||||
// X-edges when they already provide a tighter scaffold. Some PDFs draw
|
||||
// only the row-index cells in the body plus a full header row; that is
|
||||
// not dense enough for `try_build_grid`, but the header rects still define
|
||||
// the real columns. Text starts inside wide cells can otherwise split the
|
||||
// table into spurious sub-columns.
|
||||
// Derive columns from text X-position clustering
|
||||
let columns = cluster_x_positions(&page_items, 15.0);
|
||||
let text_col_edges = if columns.len() >= 2 {
|
||||
let mut edges: Vec<f32> = Vec::with_capacity(columns.len() + 1);
|
||||
let min_x = page_items.iter().map(|(_, i)| i.x).reduce(f32::min)?;
|
||||
edges.push(min_x - 5.0);
|
||||
for pair in columns.windows(2) {
|
||||
edges.push((pair[0] + pair[1]) / 2.0);
|
||||
}
|
||||
let max_x_right = page_items
|
||||
.iter()
|
||||
.map(|(_, i)| i.x + i.width)
|
||||
.reduce(f32::max)?;
|
||||
edges.push(max_x_right + 5.0);
|
||||
Some(edges)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rect_col_edges = {
|
||||
let mut x_vals = Vec::with_capacity(content_rects.len() * 2);
|
||||
for &&(x, _, w, _) in &content_rects {
|
||||
x_vals.push(x);
|
||||
x_vals.push(x + w);
|
||||
}
|
||||
let mut edges = snap_edges(&x_vals, 6.0);
|
||||
edges.sort_by(|a, b| a.total_cmp(b));
|
||||
if (3..=26).contains(&edges.len()) {
|
||||
Some(edges)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let col_edges = match (rect_col_edges, text_col_edges) {
|
||||
(Some(rect_edges), Some(text_edges)) if rect_edges.len() <= text_edges.len() => {
|
||||
debug!(
|
||||
" cell-rect using {} rect-derived columns over {} text clusters",
|
||||
rect_edges.len() - 1,
|
||||
text_edges.len() - 1
|
||||
);
|
||||
rect_edges
|
||||
}
|
||||
(_, Some(text_edges)) => text_edges,
|
||||
(Some(rect_edges), None) => rect_edges,
|
||||
(None, None) => {
|
||||
debug!(
|
||||
" cell-rect rejected: only {} columns from text clustering",
|
||||
columns.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if col_edges.len() < 3 {
|
||||
if columns.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Build column edges
|
||||
let mut col_edges: Vec<f32> = Vec::with_capacity(columns.len() + 1);
|
||||
let min_x = page_items.iter().map(|(_, i)| i.x).reduce(f32::min)?;
|
||||
col_edges.push(min_x - 5.0);
|
||||
for pair in columns.windows(2) {
|
||||
col_edges.push((pair[0] + pair[1]) / 2.0);
|
||||
}
|
||||
let max_x_right = page_items
|
||||
.iter()
|
||||
.map(|(_, i)| i.x + i.width)
|
||||
.reduce(f32::max)?;
|
||||
col_edges.push(max_x_right + 5.0);
|
||||
|
||||
let num_cols = col_edges.len() - 1;
|
||||
let num_rows = row_edges.len() - 1;
|
||||
|
||||
|
||||
+63
-847
@@ -4,385 +4,15 @@
|
||||
//! elements linked to MCIDs, this module builds `Table` structs directly from
|
||||
//! the semantic hierarchy — no geometry heuristics needed.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::debug;
|
||||
|
||||
use crate::structure_tree::{StructTable, StructTableRow};
|
||||
use crate::structure_tree::StructTable;
|
||||
use crate::types::TextItem;
|
||||
|
||||
use super::Table;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MatchedCell {
|
||||
text: String,
|
||||
item_indices: Vec<usize>,
|
||||
x: Option<f32>,
|
||||
y: Option<f32>,
|
||||
}
|
||||
|
||||
fn legacy_column_positions(
|
||||
page_rows: &[&StructTableRow],
|
||||
mcid_to_items: &HashMap<i64, Vec<usize>>,
|
||||
items: &[TextItem],
|
||||
page: u32,
|
||||
num_cols: usize,
|
||||
) -> Vec<f32> {
|
||||
let mut col_positions: Vec<f32> = vec![0.0; num_cols];
|
||||
for (col, col_pos) in col_positions.iter_mut().enumerate() {
|
||||
for row in page_rows {
|
||||
if col < row.cells.len() {
|
||||
if let Some(x) = row.cells[col]
|
||||
.mcids
|
||||
.iter()
|
||||
.filter(|(_, p)| *p == page)
|
||||
.filter_map(|(mcid, _)| mcid_to_items.get(mcid))
|
||||
.flatten()
|
||||
.map(|&idx| items[idx].x)
|
||||
.reduce(f32::min)
|
||||
{
|
||||
*col_pos = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
col_positions
|
||||
}
|
||||
|
||||
fn infer_column_positions(
|
||||
raw_rows: &[Vec<MatchedCell>],
|
||||
fallback_positions: &[f32],
|
||||
num_cols: usize,
|
||||
) -> Vec<f32> {
|
||||
const SAME_COLUMN_TOLERANCE: f32 = 18.0;
|
||||
|
||||
let mut anchors = raw_rows
|
||||
.iter()
|
||||
.max_by_key(|row| row.iter().filter(|cell| cell.x.is_some()).count())
|
||||
.map(|row| row.iter().filter_map(|cell| cell.x).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
if anchors.len() > num_cols {
|
||||
anchors.truncate(num_cols);
|
||||
}
|
||||
|
||||
let mut additional_positions: Vec<f32> = raw_rows
|
||||
.iter()
|
||||
.flat_map(|row| row.iter().filter_map(|cell| cell.x))
|
||||
.collect();
|
||||
additional_positions.sort_by(|a, b| a.total_cmp(b));
|
||||
|
||||
for x in additional_positions {
|
||||
if anchors.len() >= num_cols {
|
||||
break;
|
||||
}
|
||||
if anchors
|
||||
.iter()
|
||||
.all(|existing| (x - *existing).abs() > SAME_COLUMN_TOLERANCE)
|
||||
{
|
||||
anchors.push(x);
|
||||
anchors.sort_by(|a, b| a.total_cmp(b));
|
||||
}
|
||||
}
|
||||
|
||||
if anchors.len() < num_cols {
|
||||
for &x in fallback_positions {
|
||||
if anchors.len() >= num_cols {
|
||||
break;
|
||||
}
|
||||
if anchors
|
||||
.iter()
|
||||
.all(|existing| (x - *existing).abs() > SAME_COLUMN_TOLERANCE)
|
||||
{
|
||||
anchors.push(x);
|
||||
anchors.sort_by(|a, b| a.total_cmp(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if anchors.is_empty() {
|
||||
return fallback_positions.to_vec();
|
||||
}
|
||||
|
||||
while anchors.len() < num_cols {
|
||||
anchors.push(*anchors.last().unwrap());
|
||||
}
|
||||
|
||||
anchors
|
||||
}
|
||||
|
||||
fn align_positions_to_columns(cell_xs: &[f32], columns: &[f32]) -> Vec<usize> {
|
||||
if cell_xs.is_empty() || columns.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if cell_xs.len() >= columns.len() {
|
||||
return (0..cell_xs.len().min(columns.len())).collect();
|
||||
}
|
||||
|
||||
let mut dp = vec![vec![f32::INFINITY; columns.len() + 1]; cell_xs.len() + 1];
|
||||
let mut take = vec![vec![false; columns.len() + 1]; cell_xs.len() + 1];
|
||||
|
||||
for value in &mut dp[0] {
|
||||
*value = 0.0;
|
||||
}
|
||||
|
||||
for i in 1..=cell_xs.len() {
|
||||
for j in 1..=columns.len() {
|
||||
let skip_cost = dp[i][j - 1];
|
||||
let take_cost = dp[i - 1][j - 1] + (cell_xs[i - 1] - columns[j - 1]).abs();
|
||||
if take_cost <= skip_cost {
|
||||
dp[i][j] = take_cost;
|
||||
take[i][j] = true;
|
||||
} else {
|
||||
dp[i][j] = skip_cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut assignments_rev = Vec::with_capacity(cell_xs.len());
|
||||
let mut i = cell_xs.len();
|
||||
let mut j = columns.len();
|
||||
while i > 0 && j > 0 {
|
||||
if take[i][j] {
|
||||
assignments_rev.push(j - 1);
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else {
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
assignments_rev.reverse();
|
||||
assignments_rev
|
||||
}
|
||||
|
||||
fn align_struct_rows(
|
||||
raw_rows: &[Vec<MatchedCell>],
|
||||
col_positions: &[f32],
|
||||
) -> (Vec<Vec<String>>, Vec<f32>, Vec<usize>) {
|
||||
let mut cells: Vec<Vec<String>> = Vec::with_capacity(raw_rows.len());
|
||||
let mut row_positions: Vec<f32> = Vec::with_capacity(raw_rows.len());
|
||||
let mut all_item_indices: Vec<usize> = Vec::new();
|
||||
|
||||
for row in raw_rows {
|
||||
let present_cells: Vec<&MatchedCell> = row
|
||||
.iter()
|
||||
.filter(|cell| {
|
||||
!cell.item_indices.is_empty() || !cell.text.is_empty() || cell.x.is_some()
|
||||
})
|
||||
.collect();
|
||||
let cell_xs: Vec<f32> = present_cells.iter().filter_map(|cell| cell.x).collect();
|
||||
let assignments = if cell_xs.len() == present_cells.len() {
|
||||
align_positions_to_columns(&cell_xs, col_positions)
|
||||
} else {
|
||||
(0..present_cells.len().min(col_positions.len())).collect()
|
||||
};
|
||||
|
||||
let mut row_cells = vec![String::new(); col_positions.len()];
|
||||
for (cell, &col_idx) in present_cells.iter().zip(assignments.iter()) {
|
||||
if !cell.text.is_empty() {
|
||||
if !row_cells[col_idx].is_empty() {
|
||||
row_cells[col_idx].push(' ');
|
||||
}
|
||||
row_cells[col_idx].push_str(&cell.text);
|
||||
}
|
||||
all_item_indices.extend(cell.item_indices.iter().copied());
|
||||
}
|
||||
|
||||
let row_y = row
|
||||
.iter()
|
||||
.filter_map(|cell| cell.y)
|
||||
.reduce(f32::max)
|
||||
.unwrap_or(0.0);
|
||||
cells.push(row_cells);
|
||||
row_positions.push(row_y);
|
||||
}
|
||||
|
||||
(cells, row_positions, all_item_indices)
|
||||
}
|
||||
|
||||
fn left_align_struct_rows(
|
||||
raw_rows: &[Vec<MatchedCell>],
|
||||
num_cols: usize,
|
||||
) -> (Vec<Vec<String>>, Vec<f32>, Vec<usize>) {
|
||||
let mut cells: Vec<Vec<String>> = Vec::with_capacity(raw_rows.len());
|
||||
let mut row_positions: Vec<f32> = Vec::with_capacity(raw_rows.len());
|
||||
let mut all_item_indices: Vec<usize> = Vec::new();
|
||||
|
||||
for row in raw_rows {
|
||||
let mut row_cells: Vec<String> = row.iter().map(|cell| cell.text.clone()).collect();
|
||||
row_cells.truncate(num_cols);
|
||||
while row_cells.len() < num_cols {
|
||||
row_cells.push(String::new());
|
||||
}
|
||||
cells.push(row_cells);
|
||||
|
||||
all_item_indices.extend(
|
||||
row.iter()
|
||||
.flat_map(|cell| cell.item_indices.iter().copied()),
|
||||
);
|
||||
row_positions.push(
|
||||
row.iter()
|
||||
.filter_map(|cell| cell.y)
|
||||
.reduce(f32::max)
|
||||
.unwrap_or(0.0),
|
||||
);
|
||||
}
|
||||
|
||||
(cells, row_positions, all_item_indices)
|
||||
}
|
||||
|
||||
fn recover_unclaimed_header_row(table: &mut Table, items: &[TextItem], has_ragged_rows: bool) {
|
||||
if !has_ragged_rows || table.rows.is_empty() || table.columns.len() < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
const MAX_HEADER_DISTANCE: f32 = 90.0;
|
||||
const MAX_GAP_TO_TABLE: f32 = 35.0;
|
||||
const MAX_INTER_HEADER_GAP: f32 = 25.0;
|
||||
const MAX_HEADER_ROWS: usize = 3;
|
||||
const Y_TOLERANCE: f32 = 5.0;
|
||||
|
||||
let top_row_y = table.rows[0];
|
||||
let x_min = table.columns.first().copied().unwrap_or(0.0) - 25.0;
|
||||
let x_max = table.columns.last().copied().unwrap_or(0.0) + 120.0;
|
||||
let claimed: HashSet<usize> = table.item_indices.iter().copied().collect();
|
||||
|
||||
let mut candidate_rows: Vec<(f32, Vec<(usize, &TextItem)>)> = Vec::new();
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
if claimed.contains(&idx)
|
||||
|| item.text.trim().is_empty()
|
||||
|| item.y <= top_row_y
|
||||
|| item.y - top_row_y > MAX_HEADER_DISTANCE
|
||||
|| item.x < x_min
|
||||
|| item.x > x_max
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((_, row_items)) = candidate_rows
|
||||
.iter_mut()
|
||||
.find(|(row_y, _)| (item.y - *row_y).abs() < Y_TOLERANCE)
|
||||
{
|
||||
row_items.push((idx, item));
|
||||
} else {
|
||||
candidate_rows.push((item.y, vec![(idx, item)]));
|
||||
}
|
||||
}
|
||||
|
||||
if candidate_rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (_, row_items) in &mut candidate_rows {
|
||||
row_items.sort_by(|a, b| a.1.x.total_cmp(&b.1.x));
|
||||
}
|
||||
candidate_rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
if candidate_rows[0].0 - top_row_y > MAX_GAP_TO_TABLE {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut candidate_iter = candidate_rows.into_iter();
|
||||
let Some(first_row) = candidate_iter.next() else {
|
||||
return;
|
||||
};
|
||||
let mut selected_rows: Vec<(f32, Vec<(usize, &TextItem)>)> = vec![first_row];
|
||||
let mut prev_y = selected_rows[0].0;
|
||||
for (row_y, row_items) in candidate_iter {
|
||||
if selected_rows.len() >= MAX_HEADER_ROWS {
|
||||
break;
|
||||
}
|
||||
if row_y - prev_y > MAX_INTER_HEADER_GAP {
|
||||
break;
|
||||
}
|
||||
prev_y = row_y;
|
||||
selected_rows.push((row_y, row_items));
|
||||
}
|
||||
|
||||
if selected_rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut assigned_rows: Vec<(f32, Vec<String>, Vec<usize>)> = Vec::new();
|
||||
let mut closest_row_populated = 0usize;
|
||||
let mut combined_cols: HashSet<usize> = HashSet::new();
|
||||
|
||||
for (row_idx, (row_y, row_items)) in selected_rows.iter().enumerate() {
|
||||
if row_items.len() > table.columns.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let row_xs: Vec<f32> = row_items.iter().map(|(_, item)| item.x).collect();
|
||||
let assignments = align_positions_to_columns(&row_xs, &table.columns);
|
||||
if assignments.len() != row_items.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut row_cells = vec![String::new(); table.columns.len()];
|
||||
let mut row_indices = Vec::with_capacity(row_items.len());
|
||||
let mut populated_cols: HashSet<usize> = HashSet::new();
|
||||
|
||||
for ((idx, item), &col_idx) in row_items.iter().zip(assignments.iter()) {
|
||||
let text = item.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !row_cells[col_idx].is_empty() {
|
||||
row_cells[col_idx].push(' ');
|
||||
}
|
||||
row_cells[col_idx].push_str(text);
|
||||
row_indices.push(*idx);
|
||||
populated_cols.insert(col_idx);
|
||||
}
|
||||
|
||||
if row_idx == 0 {
|
||||
closest_row_populated = populated_cols.len();
|
||||
}
|
||||
|
||||
combined_cols.extend(populated_cols.iter().copied());
|
||||
assigned_rows.push((*row_y, row_cells, row_indices));
|
||||
}
|
||||
|
||||
let required_cols = if table.columns.len() <= 4 {
|
||||
table.columns.len()
|
||||
} else {
|
||||
table.columns.len() - 1
|
||||
};
|
||||
if closest_row_populated < 2 || combined_cols.len() < required_cols {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut header_cells = vec![String::new(); table.columns.len()];
|
||||
let mut header_indices = Vec::new();
|
||||
for (_, row_cells, row_indices) in assigned_rows.iter().rev() {
|
||||
for (col_idx, cell_text) in row_cells.iter().enumerate() {
|
||||
if cell_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !header_cells[col_idx].is_empty() {
|
||||
header_cells[col_idx].push(' ');
|
||||
}
|
||||
header_cells[col_idx].push_str(cell_text);
|
||||
}
|
||||
header_indices.extend(row_indices.iter().copied());
|
||||
}
|
||||
|
||||
table.rows.insert(
|
||||
0,
|
||||
assigned_rows
|
||||
.iter()
|
||||
.map(|(row_y, _, _)| *row_y)
|
||||
.reduce(f32::max)
|
||||
.unwrap_or(top_row_y),
|
||||
);
|
||||
table.cells.insert(0, header_cells);
|
||||
table.item_indices.extend(header_indices);
|
||||
table.item_indices.sort_unstable();
|
||||
table.item_indices.dedup();
|
||||
}
|
||||
|
||||
/// Build tables from structure-tree table descriptors by matching MCIDs to TextItems.
|
||||
///
|
||||
/// Returns tables for the given page. Tables where fewer than 50% of cells
|
||||
@@ -437,14 +67,18 @@ pub fn detect_tables_from_struct_tree(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build cell text and geometry for alignment and header recovery.
|
||||
let mut raw_rows: Vec<Vec<MatchedCell>> = Vec::new();
|
||||
// Build cell text and collect item indices
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
let mut all_item_indices: Vec<usize> = Vec::new();
|
||||
let mut total_cells = 0u32;
|
||||
let mut matched_cells = 0u32;
|
||||
|
||||
for row in &page_rows {
|
||||
let mut row_cells = Vec::with_capacity(row.cells.len());
|
||||
for cell in &row.cells {
|
||||
let mut row_cells = Vec::with_capacity(num_cols);
|
||||
for (col_idx, cell) in row.cells.iter().enumerate() {
|
||||
if col_idx >= num_cols {
|
||||
break;
|
||||
}
|
||||
total_cells += 1;
|
||||
|
||||
// Collect all items for this cell's MCIDs
|
||||
@@ -481,18 +115,18 @@ pub fn detect_tables_from_struct_tree(
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let item_indices = cell_items.iter().map(|(idx, _)| *idx).collect::<Vec<_>>();
|
||||
let x = cell_items.iter().map(|(_, item)| item.x).reduce(f32::min);
|
||||
let y = cell_items.iter().map(|(_, item)| item.y).reduce(f32::max);
|
||||
for (idx, _) in &cell_items {
|
||||
all_item_indices.push(*idx);
|
||||
}
|
||||
|
||||
row_cells.push(MatchedCell {
|
||||
text,
|
||||
item_indices,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
row_cells.push(text);
|
||||
}
|
||||
raw_rows.push(row_cells);
|
||||
|
||||
// Pad to num_cols
|
||||
while row_cells.len() < num_cols {
|
||||
row_cells.push(String::new());
|
||||
}
|
||||
cells.push(row_cells);
|
||||
}
|
||||
|
||||
// Reject if too few cells matched (stale structure tree)
|
||||
@@ -514,55 +148,52 @@ pub fn detect_tables_from_struct_tree(
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_ragged_rows = raw_rows
|
||||
.iter()
|
||||
.any(|row| row.iter().filter(|cell| cell.x.is_some()).count() < num_cols);
|
||||
let first_row_has_tagged_header = page_rows.first().is_some_and(|row| {
|
||||
let header_cells = row.cells.iter().filter(|cell| cell.is_header).count();
|
||||
header_cells * 2 >= row.cells.len()
|
||||
});
|
||||
let fallback_col_positions =
|
||||
legacy_column_positions(&page_rows, &mcid_to_items, items, page, num_cols);
|
||||
let (legacy_cells, legacy_row_positions, mut legacy_item_indices) =
|
||||
left_align_struct_rows(&raw_rows, num_cols);
|
||||
legacy_item_indices.sort_unstable();
|
||||
legacy_item_indices.dedup();
|
||||
let legacy_table = Table::new(
|
||||
fallback_col_positions.clone(),
|
||||
legacy_row_positions,
|
||||
legacy_cells,
|
||||
legacy_item_indices,
|
||||
);
|
||||
// Derive row/column positions from item geometry
|
||||
let mut row_positions: Vec<f32> = Vec::new();
|
||||
for row in &page_rows {
|
||||
let y = row
|
||||
.cells
|
||||
.iter()
|
||||
.flat_map(|c| c.mcids.iter())
|
||||
.filter(|(_, p)| *p == page)
|
||||
.filter_map(|(mcid, _)| mcid_to_items.get(mcid))
|
||||
.flatten()
|
||||
.map(|&idx| items[idx].y)
|
||||
.reduce(f32::max)
|
||||
.unwrap_or(0.0);
|
||||
row_positions.push(y);
|
||||
}
|
||||
|
||||
let col_positions = infer_column_positions(&raw_rows, &fallback_col_positions, num_cols);
|
||||
let (aligned_cells, aligned_row_positions, mut aligned_item_indices) =
|
||||
align_struct_rows(&raw_rows, &col_positions);
|
||||
aligned_item_indices.sort_unstable();
|
||||
aligned_item_indices.dedup();
|
||||
// Column positions: use X positions of first non-empty cell in each column
|
||||
let mut col_positions: Vec<f32> = vec![0.0; num_cols];
|
||||
for (col, col_pos) in col_positions.iter_mut().enumerate() {
|
||||
for row in &page_rows {
|
||||
if col < row.cells.len() {
|
||||
if let Some(x) = row.cells[col]
|
||||
.mcids
|
||||
.iter()
|
||||
.filter(|(_, p)| *p == page)
|
||||
.filter_map(|(mcid, _)| mcid_to_items.get(mcid))
|
||||
.flatten()
|
||||
.map(|&idx| items[idx].x)
|
||||
.reduce(f32::min)
|
||||
{
|
||||
*col_pos = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut aligned_table = Table::new(
|
||||
all_item_indices.sort_unstable();
|
||||
all_item_indices.dedup();
|
||||
|
||||
tables.push(Table::new(
|
||||
col_positions,
|
||||
aligned_row_positions,
|
||||
aligned_cells,
|
||||
aligned_item_indices,
|
||||
);
|
||||
let item_count_before_header = aligned_table.item_indices.len();
|
||||
let row_count_before_header = aligned_table.cells.len();
|
||||
recover_unclaimed_header_row(
|
||||
&mut aligned_table,
|
||||
items,
|
||||
has_ragged_rows && !first_row_has_tagged_header,
|
||||
);
|
||||
|
||||
let recovered_header = aligned_table.item_indices.len() > item_count_before_header
|
||||
|| aligned_table.cells.len() > row_count_before_header;
|
||||
let prefer_aligned = recovered_header;
|
||||
|
||||
tables.push(if prefer_aligned {
|
||||
aligned_table
|
||||
} else {
|
||||
legacy_table
|
||||
});
|
||||
row_positions,
|
||||
cells,
|
||||
all_item_indices,
|
||||
));
|
||||
}
|
||||
|
||||
tables
|
||||
@@ -743,419 +374,4 @@ mod tests {
|
||||
let tables = detect_tables_from_struct_tree(&items, &struct_tables, 2);
|
||||
assert_eq!(tables.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realigns_ragged_rows_and_recovers_untagged_header() {
|
||||
let items = vec![
|
||||
make_item("Category", 50.0, 120.0, 1, None),
|
||||
make_item("Potentially", 150.0, 120.0, 1, None),
|
||||
make_item("Summary", 250.0, 120.0, 1, None),
|
||||
make_item("Most commonly", 350.0, 120.0, 1, None),
|
||||
make_item("concerning aspect", 150.0, 110.0, 1, None),
|
||||
make_item("suggested", 350.0, 110.0, 1, None),
|
||||
make_item("of circumstances", 150.0, 100.0, 1, None),
|
||||
make_item("intervention", 350.0, 100.0, 1, None),
|
||||
make_item("Existence of red-teaming", 150.0, 80.0, 1, Some(10)),
|
||||
make_item("Important for safety", 250.0, 80.0, 1, Some(11)),
|
||||
make_item("Ensure welfare interviews", 350.0, 80.0, 1, Some(12)),
|
||||
make_item("Identity & self-knowledge", 50.0, 60.0, 1, Some(20)),
|
||||
make_item("Lack of knowledge", 150.0, 60.0, 1, Some(21)),
|
||||
make_item("Overall negative", 250.0, 60.0, 1, Some(22)),
|
||||
make_item("Describe training process", 350.0, 60.0, 1, Some(23)),
|
||||
make_item("Uncertainty around other copies", 150.0, 40.0, 1, Some(30)),
|
||||
make_item("High uncertainty", 250.0, 40.0, 1, Some(31)),
|
||||
make_item("No intervention suggested", 350.0, 40.0, 1, Some(32)),
|
||||
];
|
||||
|
||||
let struct_tables = vec![StructTable {
|
||||
rows: vec![
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(10, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(11, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(12, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(20, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(21, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(22, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(23, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(30, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(31, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(32, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let tables = detect_tables_from_struct_tree(&items, &struct_tables, 1);
|
||||
assert_eq!(tables.len(), 1);
|
||||
let table = &tables[0];
|
||||
assert_eq!(table.cells.len(), 4);
|
||||
assert_eq!(
|
||||
table.cells[0],
|
||||
vec![
|
||||
"Category",
|
||||
"Potentially concerning aspect of circumstances",
|
||||
"Summary",
|
||||
"Most commonly suggested intervention",
|
||||
]
|
||||
);
|
||||
assert_eq!(table.cells[1][0], "");
|
||||
assert_eq!(table.cells[1][1], "Existence of red-teaming");
|
||||
assert_eq!(table.cells[2][0], "Identity & self-knowledge");
|
||||
assert_eq!(table.cells[3][0], "");
|
||||
assert_eq!(table.columns.len(), 4);
|
||||
assert!(table.columns.windows(2).all(|w| w[0] < w[1]));
|
||||
assert_eq!(table.item_indices.len(), items.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_absorb_caption_above_ragged_struct_table() {
|
||||
let items = vec![
|
||||
make_item("Table 5-7: Summary of responses", 50.0, 120.0, 1, None),
|
||||
make_item("Aspect one", 150.0, 80.0, 1, Some(10)),
|
||||
make_item("Summary one", 250.0, 80.0, 1, Some(11)),
|
||||
make_item("Category", 50.0, 60.0, 1, Some(20)),
|
||||
make_item("Aspect two", 150.0, 60.0, 1, Some(21)),
|
||||
make_item("Summary two", 250.0, 60.0, 1, Some(22)),
|
||||
make_item("Aspect three", 150.0, 40.0, 1, Some(30)),
|
||||
make_item("Summary three", 250.0, 40.0, 1, Some(31)),
|
||||
];
|
||||
|
||||
let struct_tables = vec![StructTable {
|
||||
rows: vec![
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(10, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(11, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(20, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(21, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(22, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(30, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(31, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let tables = detect_tables_from_struct_tree(&items, &struct_tables, 1);
|
||||
assert_eq!(tables.len(), 1);
|
||||
let table = &tables[0];
|
||||
assert_eq!(table.cells.len(), 3);
|
||||
assert!(
|
||||
table
|
||||
.cells
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|cell| !cell.contains("Table 5-7")),
|
||||
"caption must stay outside the table"
|
||||
);
|
||||
assert!(!table.item_indices.contains(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_tagged_header_without_absorbing_intro_or_caption() {
|
||||
let items = vec![
|
||||
make_item(
|
||||
"Eighteen people left other comments regarding the Project.",
|
||||
50.0,
|
||||
130.0,
|
||||
1,
|
||||
None,
|
||||
),
|
||||
make_item("Table 5-1:", 220.0, 130.0, 1, None),
|
||||
make_item("Other Comments", 350.0, 130.0, 1, None),
|
||||
make_item("Theme", 50.0, 110.0, 1, Some(10)),
|
||||
make_item("Specific Concern/Inquiry", 200.0, 110.0, 1, Some(11)),
|
||||
make_item("Response", 420.0, 110.0, 1, Some(12)),
|
||||
make_item("Traffic", 50.0, 90.0, 1, Some(20)),
|
||||
make_item("Road conditions", 200.0, 90.0, 1, Some(21)),
|
||||
make_item("Maintenance response", 420.0, 90.0, 1, Some(22)),
|
||||
make_item("Noise", 50.0, 70.0, 1, Some(30)),
|
||||
make_item("Dust concerns", 200.0, 70.0, 1, Some(31)),
|
||||
make_item("Mitigation response", 420.0, 70.0, 1, Some(32)),
|
||||
make_item("Resource Use", 50.0, 50.0, 1, Some(40)),
|
||||
make_item("Snowmobile trails", 200.0, 50.0, 1, Some(41)),
|
||||
make_item("Access response", 420.0, 50.0, 1, Some(42)),
|
||||
];
|
||||
|
||||
let struct_tables = vec![StructTable {
|
||||
rows: vec![
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(10, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(11, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(12, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(20, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(21, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(22, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(30, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(31, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(32, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(40, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(41, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(42, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let tables = detect_tables_from_struct_tree(&items, &struct_tables, 1);
|
||||
assert_eq!(tables.len(), 1);
|
||||
let table = &tables[0];
|
||||
assert_eq!(table.cells.len(), 4);
|
||||
assert_eq!(
|
||||
table.cells[0],
|
||||
vec!["Theme", "Specific Concern/Inquiry", "Response"]
|
||||
);
|
||||
assert!(!table.item_indices.contains(&0));
|
||||
assert!(!table.item_indices.contains(&1));
|
||||
assert!(!table.item_indices.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_recover_header_for_narrow_two_column_table() {
|
||||
let items = vec![
|
||||
make_item("Alpha", 50.0, 120.0, 1, None),
|
||||
make_item("Beta", 200.0, 120.0, 1, None),
|
||||
make_item("First value", 200.0, 80.0, 1, Some(10)),
|
||||
make_item("Only labeled row", 50.0, 60.0, 1, Some(20)),
|
||||
make_item("Second value", 200.0, 60.0, 1, Some(21)),
|
||||
make_item("Third value", 200.0, 40.0, 1, Some(30)),
|
||||
];
|
||||
|
||||
let struct_tables = vec![StructTable {
|
||||
rows: vec![
|
||||
StructTableRow {
|
||||
cells: vec![StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(10, 1)],
|
||||
}],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(20, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(21, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(30, 1)],
|
||||
}],
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let tables = detect_tables_from_struct_tree(&items, &struct_tables, 1);
|
||||
assert_eq!(tables.len(), 1);
|
||||
let table = &tables[0];
|
||||
assert_eq!(table.cells.len(), 3);
|
||||
assert_eq!(table.cells[0], vec!["First value", ""]);
|
||||
assert_eq!(table.cells[1], vec!["Only labeled row", "Second value"]);
|
||||
assert_eq!(table.cells[2], vec!["Third value", ""]);
|
||||
assert!(!table.item_indices.contains(&0));
|
||||
assert!(!table.item_indices.contains(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ragged_rows_without_recovered_header_keep_legacy_alignment() {
|
||||
let items = vec![
|
||||
make_item("Date", 150.0, 120.0, 1, Some(10)),
|
||||
make_item("Title", 250.0, 120.0, 1, Some(11)),
|
||||
make_item("PE", 350.0, 120.0, 1, Some(12)),
|
||||
make_item("Bidder", 450.0, 120.0, 1, Some(13)),
|
||||
make_item("Amount", 550.0, 120.0, 1, Some(14)),
|
||||
make_item("1", 50.0, 100.0, 1, Some(20)),
|
||||
make_item("8/1", 150.0, 100.0, 1, Some(21)),
|
||||
make_item("Procurement", 250.0, 100.0, 1, Some(22)),
|
||||
make_item("PUC", 350.0, 100.0, 1, Some(23)),
|
||||
make_item("Vendor", 450.0, 100.0, 1, Some(24)),
|
||||
make_item("SR1", 550.0, 100.0, 1, Some(25)),
|
||||
];
|
||||
|
||||
let struct_tables = vec![StructTable {
|
||||
rows: vec![
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(10, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(11, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(12, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(13, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: true,
|
||||
mcids: vec![(14, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
StructTableRow {
|
||||
cells: vec![
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(20, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(21, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(22, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(23, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(24, 1)],
|
||||
},
|
||||
StructTableCell {
|
||||
is_header: false,
|
||||
mcids: vec![(25, 1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let tables = detect_tables_from_struct_tree(&items, &struct_tables, 1);
|
||||
assert_eq!(tables.len(), 1);
|
||||
let table = &tables[0];
|
||||
assert_eq!(table.cells[0][0], "Date");
|
||||
assert_eq!(table.cells[0][4], "Amount");
|
||||
assert_eq!(table.cells[0][5], "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,12 +154,6 @@ fn is_dots_only(cell: &str) -> bool {
|
||||
dots >= 3 && t.chars().all(|c| c == '.' || c.is_whitespace())
|
||||
}
|
||||
|
||||
fn starts_with_uppercase_word(cell: &str) -> bool {
|
||||
cell.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.is_some_and(|c| c.is_uppercase())
|
||||
}
|
||||
|
||||
/// Clean up table cells: merge continuation rows, extract footnotes, remove empty rows
|
||||
fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
let mut cleaned: Vec<Vec<String>> = Vec::new();
|
||||
@@ -218,20 +212,11 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
let looks_like_data_row = non_first_cells.len() >= 2
|
||||
&& avg_cell_len <= 10.0
|
||||
&& numeric_cells > non_first_cells.len() / 2;
|
||||
let uppercase_leading_cells = non_first_cells
|
||||
.iter()
|
||||
.filter(|cell| starts_with_uppercase_word(cell))
|
||||
.count();
|
||||
let looks_like_spanning_first_column_row = first_cell.is_empty()
|
||||
&& row.len() >= 4
|
||||
&& non_first_cells.len() == row.len().saturating_sub(1)
|
||||
&& uppercase_leading_cells >= non_first_cells.len().saturating_sub(1);
|
||||
// Classic continuation: first cell empty, content in other cells
|
||||
let is_classic_continuation = first_cell.is_empty()
|
||||
&& !non_first_cells.is_empty()
|
||||
&& !is_short_subheader
|
||||
&& !looks_like_data_row
|
||||
&& !looks_like_spanning_first_column_row
|
||||
&& cleaned.len() > 1;
|
||||
|
||||
// Wrapped-cell continuation: row has fewer filled cells than the header
|
||||
@@ -261,7 +246,6 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
&& filled_cells <= max_filled_for_merge
|
||||
&& prev_filled > filled_cells
|
||||
&& !looks_like_data_row
|
||||
&& !looks_like_spanning_first_column_row
|
||||
&& !is_short_subheader;
|
||||
|
||||
let is_continuation = is_classic_continuation || is_wrapped_continuation;
|
||||
@@ -431,65 +415,6 @@ mod tests {
|
||||
assert_eq!(cleaned.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_spanning_first_column_row_not_merged() {
|
||||
let cells = vec![
|
||||
vec![
|
||||
"Category".into(),
|
||||
"Potentially concerning aspect".into(),
|
||||
"Summary".into(),
|
||||
"Intervention".into(),
|
||||
],
|
||||
vec![
|
||||
"Identity & self-knowledge".into(),
|
||||
"Lack of knowledge".into(),
|
||||
"Overall negative".into(),
|
||||
"Describe training".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"Uncertainty around other copies".into(),
|
||||
"High uncertainty".into(),
|
||||
"No intervention suggested".into(),
|
||||
],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
assert_eq!(cleaned.len(), 3);
|
||||
assert_eq!(cleaned[2][0], "");
|
||||
assert_eq!(cleaned[2][1], "Uncertainty around other copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_full_width_continuation_row_still_merges_when_lowercase() {
|
||||
let cells = vec![
|
||||
vec![
|
||||
"Classification".into(),
|
||||
"Before tax".into(),
|
||||
"After tax".into(),
|
||||
"Standard equipment".into(),
|
||||
"Options".into(),
|
||||
],
|
||||
vec![
|
||||
"Exclusive Special".into(),
|
||||
"83,500,000".into(),
|
||||
"79,275,000".into(),
|
||||
"Standard equipment".into(),
|
||||
"Option A".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"with 3.5% individual consumption tax applied".into(),
|
||||
"with 3.5% individual consumption tax applied".into(),
|
||||
"lighting(crash pad)".into(),
|
||||
"sound system".into(),
|
||||
],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
assert_eq!(cleaned.len(), 2);
|
||||
assert!(cleaned[1][1].contains("83,500,000"));
|
||||
assert!(cleaned[1][1].contains("with 3.5%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_header_row_not_merged() {
|
||||
// Continuation requires cleaned.len() > 1 (don't merge into header)
|
||||
|
||||
@@ -9,7 +9,6 @@ mod detect_struct;
|
||||
mod financial;
|
||||
mod format;
|
||||
mod grid;
|
||||
pub mod structured;
|
||||
|
||||
pub use detect_heuristic::detect_tables;
|
||||
pub(crate) use detect_heuristic::is_table_of_contents;
|
||||
@@ -18,7 +17,6 @@ pub(crate) use detect_rects::cluster_rects;
|
||||
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
|
||||
pub use detect_struct::detect_tables_from_struct_tree;
|
||||
pub use format::table_to_markdown;
|
||||
pub use structured::{cells_to_markdown, StructuredCell};
|
||||
|
||||
use crate::types::TextItem;
|
||||
|
||||
|
||||
@@ -1,972 +0,0 @@
|
||||
//! Structure-recovery-aware (TSR) table assembly.
|
||||
//!
|
||||
//! Consumes the raw output of an external table-structure recognition model
|
||||
//! (e.g. SLANet on PaddleOCR): a flat list of HTML structure tokens plus a
|
||||
//! parallel list of per-cell bboxes. Pairs each cell open-tag with its bbox
|
||||
//! in document order, tracks row/column position with rowspan/colspan
|
||||
//! awareness, and emits a markdown pipe-table.
|
||||
//!
|
||||
//! No real HTML parser is needed — the token grammar is restricted (see
|
||||
//! [`parse_structure`]), so a small state machine is enough.
|
||||
//!
|
||||
//! Cell text is supplied separately by the caller (typically by overlap-
|
||||
//! testing PDF text items against each cell's page-PDF-pt bbox).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// A single resolved cell, with both structural metadata and its bbox in
|
||||
/// page PDF-points (top-left origin).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuredCell {
|
||||
/// 0-indexed grid row.
|
||||
pub row: usize,
|
||||
/// 0-indexed grid column.
|
||||
pub col: usize,
|
||||
/// 1 for a normal cell.
|
||||
pub rowspan: usize,
|
||||
/// 1 for a normal cell.
|
||||
pub colspan: usize,
|
||||
/// `true` when the cell is a `<th>` or sits inside `<thead>`.
|
||||
pub is_header: bool,
|
||||
/// Cell text (filled in by the caller after overlap-testing PDF items).
|
||||
pub text: String,
|
||||
/// Axis-aligned bbox `[x1, y1, x2, y2]` in page PDF-points, top-left origin.
|
||||
pub page_pt_bbox: [f32; 4],
|
||||
}
|
||||
|
||||
/// Intermediate parse result before the caller fills in text + page coords.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CellSlot {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub rowspan: usize,
|
||||
pub colspan: usize,
|
||||
pub is_header: bool,
|
||||
/// Index into the parallel `cell_bboxes` array.
|
||||
pub bbox_idx: usize,
|
||||
}
|
||||
|
||||
/// Parse a sequence of SLANet structure tokens into ordered cell slots.
|
||||
///
|
||||
/// Token grammar (no real HTML parsing required):
|
||||
/// - Section markers: `<thead>`, `</thead>`, `<tbody>`, `</tbody>` and
|
||||
/// wrapper tokens (`<html>`, `<body>`, `<table>`, plus closing variants)
|
||||
/// are tracked or skipped.
|
||||
/// - Row markers: `<tr>` opens a new row, `</tr>` is informational.
|
||||
/// - Empty cell, single token: `<td></td>` or `<th></th>`.
|
||||
/// - Cell with attributes, multi-token sequence: `<td` (or `<th`), then
|
||||
/// attribute fragments like ` colspan="4"`, then `>`, then later `</td>`
|
||||
/// (or `</th>`). Cells get paired with the next bbox in document order.
|
||||
///
|
||||
/// Cells inside `<thead>` and any `<th>` cells are flagged as headers.
|
||||
/// rowspan/colspan attributes are honoured and prior-row rowspans push
|
||||
/// later-row cells to the right.
|
||||
pub(crate) fn parse_structure(tokens: &[String]) -> Vec<CellSlot> {
|
||||
let mut slots: Vec<CellSlot> = Vec::new();
|
||||
let mut occupied: HashSet<(usize, usize)> = HashSet::new();
|
||||
let mut row: usize = 0;
|
||||
let mut col: usize = 0;
|
||||
let mut bbox_idx: usize = 0;
|
||||
let mut in_thead = false;
|
||||
let mut started_first_row = false;
|
||||
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let tok = tokens[i].trim();
|
||||
match tok {
|
||||
"<thead>" => {
|
||||
in_thead = true;
|
||||
}
|
||||
"</thead>" => {
|
||||
in_thead = false;
|
||||
}
|
||||
"<tr>" => {
|
||||
if started_first_row {
|
||||
row += 1;
|
||||
}
|
||||
col = 0;
|
||||
started_first_row = true;
|
||||
}
|
||||
"<td></td>" | "<th></th>" => {
|
||||
let is_th = tok == "<th></th>";
|
||||
while occupied.contains(&(row, col)) {
|
||||
col += 1;
|
||||
}
|
||||
slots.push(CellSlot {
|
||||
row,
|
||||
col,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: in_thead || is_th,
|
||||
bbox_idx,
|
||||
});
|
||||
bbox_idx += 1;
|
||||
col += 1;
|
||||
}
|
||||
"<td" | "<th" => {
|
||||
let is_th = tok == "<th";
|
||||
let mut rowspan: usize = 1;
|
||||
let mut colspan: usize = 1;
|
||||
// Consume attribute fragments until we hit ">".
|
||||
i += 1;
|
||||
while i < tokens.len() && tokens[i].trim() != ">" {
|
||||
let attr = tokens[i].as_str();
|
||||
if let Some(v) = parse_int_attr(attr, "rowspan") {
|
||||
rowspan = v.max(1);
|
||||
} else if let Some(v) = parse_int_attr(attr, "colspan") {
|
||||
colspan = v.max(1);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// i now points at the `>` token (or off the end if malformed).
|
||||
while occupied.contains(&(row, col)) {
|
||||
col += 1;
|
||||
}
|
||||
slots.push(CellSlot {
|
||||
row,
|
||||
col,
|
||||
rowspan,
|
||||
colspan,
|
||||
is_header: in_thead || is_th,
|
||||
bbox_idx,
|
||||
});
|
||||
for r in row..row + rowspan {
|
||||
for c in col..col + colspan {
|
||||
occupied.insert((r, c));
|
||||
}
|
||||
}
|
||||
bbox_idx += 1;
|
||||
col += colspan;
|
||||
}
|
||||
// Wrapper / informational tokens — no-op.
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
slots
|
||||
}
|
||||
|
||||
/// Parse an attribute fragment like ` colspan="4"` or `rowspan='2'`.
|
||||
///
|
||||
/// Tolerates leading whitespace and either single or double quotes.
|
||||
fn parse_int_attr(s: &str, name: &str) -> Option<usize> {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.starts_with(name) {
|
||||
return None;
|
||||
}
|
||||
let rest = trimmed[name.len()..].trim_start();
|
||||
let rest = rest.strip_prefix('=')?.trim_start();
|
||||
let value = rest
|
||||
.trim_start_matches(['"', '\''])
|
||||
.trim_end_matches(['"', '\'']);
|
||||
value.parse().ok()
|
||||
}
|
||||
|
||||
/// Convert a SLANet polygon (4 or 8 elements) into an axis-aligned
|
||||
/// `[x1, y1, x2, y2]` rect.
|
||||
///
|
||||
/// 8-element form: `[x1,y1, x2,y1, x2,y2, x1,y2]` (4 corners). We ignore the
|
||||
/// implicit corner order and just take min/max so rotated polygons collapse
|
||||
/// to a sane bounding box.
|
||||
///
|
||||
/// 4-element form: `[x1, y1, x2, y2]` (axis-aligned, older SLANet variants).
|
||||
pub(crate) fn polygon_to_aabb(coords: &[f32]) -> Option<[f32; 4]> {
|
||||
match coords.len() {
|
||||
4 => {
|
||||
let x1 = coords[0].min(coords[2]);
|
||||
let y1 = coords[1].min(coords[3]);
|
||||
let x2 = coords[0].max(coords[2]);
|
||||
let y2 = coords[1].max(coords[3]);
|
||||
Some([x1, y1, x2, y2])
|
||||
}
|
||||
8 => {
|
||||
let xs = [coords[0], coords[2], coords[4], coords[6]];
|
||||
let ys = [coords[1], coords[3], coords[5], coords[7]];
|
||||
let x1 = xs.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let y1 = ys.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let x2 = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let y2 = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
if x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite() {
|
||||
Some([x1, y1, x2, y2])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a cell rect from crop image-pixel space to page PDF-points
|
||||
/// (top-left origin), given the crop's PDF-point offset on the page and the
|
||||
/// DPI the crop image was rendered at.
|
||||
pub(crate) fn cell_px_to_page_pt(
|
||||
cell_px: [f32; 4],
|
||||
render_dpi: f32,
|
||||
crop_origin_pt: [f32; 2],
|
||||
) -> [f32; 4] {
|
||||
let pt_per_px = if render_dpi > 0.0 {
|
||||
72.0 / render_dpi
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let [x_off, y_off] = crop_origin_pt;
|
||||
[
|
||||
cell_px[0] * pt_per_px + x_off,
|
||||
cell_px[1] * pt_per_px + y_off,
|
||||
cell_px[2] * pt_per_px + x_off,
|
||||
cell_px[3] * pt_per_px + y_off,
|
||||
]
|
||||
}
|
||||
|
||||
/// Refine TSR cell bboxes into non-overlapping row/column bands.
|
||||
///
|
||||
/// SLANet-style bboxes are often plausible but too tall on dense borderless
|
||||
/// tables. Native PDF text assignment is more reliable when each parsed row
|
||||
/// owns the band between neighboring row centers instead of the full model box.
|
||||
pub(crate) fn normalize_cell_bands(cells: &mut [StructuredCell]) {
|
||||
if cells.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let row_bands = derive_axis_bands(cells, Axis::Y);
|
||||
let col_bands = derive_axis_bands(cells, Axis::X);
|
||||
|
||||
for cell in cells {
|
||||
let row_end = cell.row + cell.rowspan.max(1).saturating_sub(1);
|
||||
if let (Some(&(y1, _)), Some(&(_, y2))) =
|
||||
(row_bands.get(&cell.row), row_bands.get(&row_end))
|
||||
{
|
||||
let clamped_y1 = cell.page_pt_bbox[1].max(y1);
|
||||
let clamped_y2 = cell.page_pt_bbox[3].min(y2);
|
||||
if clamped_y1 < clamped_y2 {
|
||||
cell.page_pt_bbox[1] = clamped_y1;
|
||||
cell.page_pt_bbox[3] = clamped_y2;
|
||||
}
|
||||
}
|
||||
|
||||
let col_end = cell.col + cell.colspan.max(1).saturating_sub(1);
|
||||
if let (Some(&(x1, _)), Some(&(_, x2))) =
|
||||
(col_bands.get(&cell.col), col_bands.get(&col_end))
|
||||
{
|
||||
let clamped_x1 = cell.page_pt_bbox[0].max(x1);
|
||||
let clamped_x2 = cell.page_pt_bbox[2].min(x2);
|
||||
if clamped_x1 < clamped_x2 {
|
||||
cell.page_pt_bbox[0] = clamped_x1;
|
||||
cell.page_pt_bbox[2] = clamped_x2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Axis {
|
||||
X,
|
||||
Y,
|
||||
}
|
||||
|
||||
fn derive_axis_bands(cells: &[StructuredCell], axis: Axis) -> HashMap<usize, (f32, f32)> {
|
||||
let mut by_index: HashMap<usize, Vec<(f32, f32)>> = HashMap::new();
|
||||
|
||||
// Prefer non-spanning cells so colspan/rowspan boxes do not skew a single
|
||||
// column/row center. If an axis has no non-spanning examples for an index,
|
||||
// fall back to anchored cells below.
|
||||
for cell in cells {
|
||||
let span = match axis {
|
||||
Axis::X => cell.colspan.max(1),
|
||||
Axis::Y => cell.rowspan.max(1),
|
||||
};
|
||||
if span == 1 {
|
||||
let idx = match axis {
|
||||
Axis::X => cell.col,
|
||||
Axis::Y => cell.row,
|
||||
};
|
||||
by_index
|
||||
.entry(idx)
|
||||
.or_default()
|
||||
.push(axis_bounds(cell.page_pt_bbox, axis));
|
||||
}
|
||||
}
|
||||
|
||||
for cell in cells {
|
||||
let idx = match axis {
|
||||
Axis::X => cell.col,
|
||||
Axis::Y => cell.row,
|
||||
};
|
||||
if !by_index.contains_key(&idx) {
|
||||
by_index
|
||||
.entry(idx)
|
||||
.or_default()
|
||||
.push(axis_bounds(cell.page_pt_bbox, axis));
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows: Vec<(usize, f32, f32, f32)> = by_index
|
||||
.into_iter()
|
||||
.filter_map(|(idx, bounds)| {
|
||||
let mut min_edge = f32::INFINITY;
|
||||
let mut max_edge = f32::NEG_INFINITY;
|
||||
let mut center_sum = 0.0;
|
||||
let mut count = 0usize;
|
||||
for (lo, hi) in bounds {
|
||||
if lo.is_finite() && hi.is_finite() && lo < hi {
|
||||
min_edge = min_edge.min(lo);
|
||||
max_edge = max_edge.max(hi);
|
||||
center_sum += (lo + hi) * 0.5;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(count > 0).then_some((idx, center_sum / count as f32, min_edge, max_edge))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if rows.len() < 2 {
|
||||
return rows
|
||||
.into_iter()
|
||||
.map(|(idx, _center, lo, hi)| (idx, (lo, hi)))
|
||||
.collect();
|
||||
}
|
||||
|
||||
rows.sort_by_key(|(idx, _, _, _)| *idx);
|
||||
|
||||
let mut bands = HashMap::new();
|
||||
for i in 0..rows.len() {
|
||||
let (idx, _center, min_edge, max_edge) = rows[i];
|
||||
let lo = if i == 0 {
|
||||
min_edge
|
||||
} else {
|
||||
(rows[i - 1].1 + rows[i].1) * 0.5
|
||||
};
|
||||
let hi = if i + 1 == rows.len() {
|
||||
max_edge
|
||||
} else {
|
||||
(rows[i].1 + rows[i + 1].1) * 0.5
|
||||
};
|
||||
if lo.is_finite() && hi.is_finite() && lo < hi {
|
||||
bands.insert(idx, (lo, hi));
|
||||
}
|
||||
}
|
||||
|
||||
bands
|
||||
}
|
||||
|
||||
fn axis_bounds(bbox: [f32; 4], axis: Axis) -> (f32, f32) {
|
||||
match axis {
|
||||
Axis::X => (bbox[0].min(bbox[2]), bbox[0].max(bbox[2])),
|
||||
Axis::Y => (bbox[1].min(bbox[3]), bbox[1].max(bbox[3])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize cell text for inclusion in a markdown pipe-table cell:
|
||||
/// collapse whitespace runs, drop newlines/tabs (cells must be one line),
|
||||
/// and escape pipes that would otherwise break the table.
|
||||
fn sanitize_cell(text: &str) -> String {
|
||||
let mut s = String::with_capacity(text.len());
|
||||
let mut prev_space = false;
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'|' => {
|
||||
s.push_str("\\|");
|
||||
prev_space = false;
|
||||
}
|
||||
'\n' | '\r' | '\t' | ' ' => {
|
||||
if !prev_space {
|
||||
s.push(' ');
|
||||
}
|
||||
prev_space = true;
|
||||
}
|
||||
other => {
|
||||
s.push(other);
|
||||
prev_space = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
/// Render a list of explicitly-positioned cells as a markdown pipe-table.
|
||||
///
|
||||
/// Grid dimensions are inferred from the cells' (row, col, rowspan, colspan)
|
||||
/// extents. A cell with colspan/rowspan > 1 is rendered in its top-left
|
||||
/// position; the absorbed grid positions are emitted as empty cells so the
|
||||
/// markdown stays a valid rectangular grid that downstream readers can
|
||||
/// column-count correctly.
|
||||
///
|
||||
/// The separator row (`|---|...|`) is emitted after the **last** row that
|
||||
/// contains a header cell (`is_header == true`). When no cells are flagged
|
||||
/// as headers — e.g. the upstream TSR model didn't emit `<thead>`/`<th>` —
|
||||
/// the separator falls back to "after row 0" so the output is still a
|
||||
/// valid pipe-table.
|
||||
pub fn cells_to_markdown(cells: &[StructuredCell]) -> String {
|
||||
if cells.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let num_rows = cells
|
||||
.iter()
|
||||
.map(|c| c.row + c.rowspan.max(1))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let num_cols = cells
|
||||
.iter()
|
||||
.map(|c| c.col + c.colspan.max(1))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if num_rows == 0 || num_cols == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Separator goes after the last header row, falling back to row 0 when
|
||||
// no header cells exist. Clamped into range so a malformed cell with
|
||||
// row >= num_rows can't push it past the table.
|
||||
let separator_after_row = cells
|
||||
.iter()
|
||||
.filter(|c| c.is_header)
|
||||
.map(|c| c.row)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(num_rows.saturating_sub(1));
|
||||
|
||||
let mut grid: Vec<Vec<String>> = vec![vec![String::new(); num_cols]; num_rows];
|
||||
for cell in cells {
|
||||
if cell.row < num_rows && cell.col < num_cols {
|
||||
grid[cell.row][cell.col] = sanitize_cell(&cell.text);
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
for (row_idx, row) in grid.iter().enumerate() {
|
||||
output.push('|');
|
||||
for cell in row {
|
||||
output.push_str(cell);
|
||||
output.push('|');
|
||||
}
|
||||
output.push('\n');
|
||||
if row_idx == separator_after_row {
|
||||
output.push('|');
|
||||
for _ in 0..num_cols {
|
||||
output.push_str("---|");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
/// Tokens for the synthetic 3×3 grid example (one colspan-4 row + two
|
||||
/// data rows of 4 cells each = 9 cells total, 3 rows × 4 cols).
|
||||
fn synthetic_3x3_tokens() -> Vec<String> {
|
||||
vec![
|
||||
"<html>",
|
||||
"<body>",
|
||||
"<table>",
|
||||
"<tbody>",
|
||||
"<tr>",
|
||||
"<td",
|
||||
" colspan=\"4\"",
|
||||
">",
|
||||
"</td>",
|
||||
"</tr>",
|
||||
"<tr>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"</tr>",
|
||||
"<tr>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"</tr>",
|
||||
"</tbody>",
|
||||
"</table>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
]
|
||||
.into_iter()
|
||||
.map(t)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bboxes for the synthetic 3×3 grid (8-element polygon form), all
|
||||
/// within a 400×120 px crop.
|
||||
fn synthetic_3x3_bboxes() -> Vec<Vec<f32>> {
|
||||
vec![
|
||||
vec![3.0, 2.0, 395.0, 2.0, 396.0, 59.0, 3.0, 59.0],
|
||||
vec![26.0, 62.0, 140.0, 62.0, 141.0, 120.0, 26.0, 120.0],
|
||||
vec![149.0, 64.0, 248.0, 64.0, 248.0, 119.0, 149.0, 119.0],
|
||||
vec![257.0, 64.0, 350.0, 64.0, 350.0, 119.0, 257.0, 119.0],
|
||||
vec![359.0, 64.0, 395.0, 64.0, 395.0, 119.0, 359.0, 119.0],
|
||||
vec![26.0, 122.0, 140.0, 122.0, 140.0, 178.0, 26.0, 178.0],
|
||||
vec![149.0, 124.0, 248.0, 124.0, 248.0, 179.0, 149.0, 179.0],
|
||||
vec![257.0, 124.0, 350.0, 124.0, 350.0, 179.0, 257.0, 179.0],
|
||||
vec![359.0, 124.0, 395.0, 124.0, 395.0, 179.0, 359.0, 179.0],
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_structure_synthetic_3x3() {
|
||||
let tokens = synthetic_3x3_tokens();
|
||||
let slots = parse_structure(&tokens);
|
||||
|
||||
assert_eq!(slots.len(), 9, "should parse 9 cells");
|
||||
|
||||
// Cell 0: row 0 col 0, colspan 4
|
||||
assert_eq!(slots[0].row, 0);
|
||||
assert_eq!(slots[0].col, 0);
|
||||
assert_eq!(slots[0].colspan, 4);
|
||||
assert_eq!(slots[0].rowspan, 1);
|
||||
|
||||
// Cells 1..5: row 1, cols 0..3
|
||||
for (i, slot) in slots.iter().enumerate().skip(1).take(4) {
|
||||
assert_eq!(slot.row, 1, "cell {i}: row should be 1");
|
||||
assert_eq!(slot.col, i - 1, "cell {i}: col should be {}", i - 1);
|
||||
assert_eq!(slot.colspan, 1);
|
||||
assert_eq!(slot.rowspan, 1);
|
||||
}
|
||||
|
||||
// Cells 5..9: row 2, cols 0..3
|
||||
for (i, slot) in slots.iter().enumerate().skip(5).take(4) {
|
||||
assert_eq!(slot.row, 2, "cell {i}: row should be 2");
|
||||
assert_eq!(slot.col, i - 5);
|
||||
assert_eq!(slot.colspan, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_to_aabb_8elt() {
|
||||
// Synthetic cell bbox 0
|
||||
let coords = vec![3.0, 2.0, 395.0, 2.0, 396.0, 59.0, 3.0, 59.0];
|
||||
let aabb = polygon_to_aabb(&coords).unwrap();
|
||||
assert_eq!(aabb, [3.0, 2.0, 396.0, 59.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_to_aabb_4elt() {
|
||||
let coords = vec![5.0, 10.0, 50.0, 60.0];
|
||||
let aabb = polygon_to_aabb(&coords).unwrap();
|
||||
assert_eq!(aabb, [5.0, 10.0, 50.0, 60.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_to_aabb_4elt_unordered() {
|
||||
// Caller may pass corners in any order; min/max should normalise.
|
||||
let coords = vec![50.0, 60.0, 5.0, 10.0];
|
||||
let aabb = polygon_to_aabb(&coords).unwrap();
|
||||
assert_eq!(aabb, [5.0, 10.0, 50.0, 60.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_to_aabb_invalid_len() {
|
||||
assert!(polygon_to_aabb(&[1.0, 2.0, 3.0]).is_none());
|
||||
assert!(polygon_to_aabb(&[1.0; 6]).is_none());
|
||||
assert!(polygon_to_aabb(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_3x3_aabbs_inside_crop() {
|
||||
// All 9 bboxes should produce valid (x1<x2, y1<y2) rects within the
|
||||
// crop bounds (400 wide, ~180 tall by inspection of the fixture).
|
||||
let bboxes = synthetic_3x3_bboxes();
|
||||
assert_eq!(bboxes.len(), 9);
|
||||
for (i, bb) in bboxes.iter().enumerate() {
|
||||
let aabb = polygon_to_aabb(bb).unwrap_or_else(|| panic!("bbox {i} invalid"));
|
||||
assert!(aabb[0] < aabb[2], "bbox {i}: x1 < x2");
|
||||
assert!(aabb[1] < aabb[3], "bbox {i}: y1 < y2");
|
||||
assert!(aabb[0] >= 0.0 && aabb[2] <= 500.0, "bbox {i}: within crop");
|
||||
assert!(aabb[1] >= 0.0 && aabb[3] <= 200.0, "bbox {i}: within crop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cell_bands_splits_overlapping_slanet_rows() {
|
||||
let mut cells = vec![
|
||||
StructuredCell {
|
||||
row: 0,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: true,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [10.0, 100.0, 90.0, 120.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 0,
|
||||
col: 1,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: true,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [90.0, 100.0, 170.0, 120.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [10.0, 116.0, 90.0, 136.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 1,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [90.0, 116.0, 170.0, 136.0],
|
||||
},
|
||||
];
|
||||
|
||||
normalize_cell_bands(&mut cells);
|
||||
|
||||
assert_eq!(cells[0].page_pt_bbox[3], cells[2].page_pt_bbox[1]);
|
||||
assert_eq!(cells[1].page_pt_bbox[3], cells[3].page_pt_bbox[1]);
|
||||
assert!(
|
||||
(cells[0].page_pt_bbox[3] - 118.0).abs() < 0.01,
|
||||
"row separator should be midpoint between row centers: {:?}",
|
||||
cells
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cell_bands_preserves_colspan_extent() {
|
||||
let mut cells = vec![
|
||||
StructuredCell {
|
||||
row: 0,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 2,
|
||||
is_header: true,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [8.0, 80.0, 172.0, 98.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [10.0, 96.0, 90.0, 114.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 1,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: String::new(),
|
||||
page_pt_bbox: [88.0, 96.0, 170.0, 114.0],
|
||||
},
|
||||
];
|
||||
|
||||
normalize_cell_bands(&mut cells);
|
||||
|
||||
assert!(
|
||||
cells[0].page_pt_bbox[0] <= cells[1].page_pt_bbox[0],
|
||||
"spanning cell should retain the first column's left edge"
|
||||
);
|
||||
assert!(
|
||||
cells[0].page_pt_bbox[2] >= cells[2].page_pt_bbox[2],
|
||||
"spanning cell should retain the last column's right edge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_int_attr_basic() {
|
||||
assert_eq!(parse_int_attr(" colspan=\"4\"", "colspan"), Some(4));
|
||||
assert_eq!(parse_int_attr(" rowspan=\"2\"", "rowspan"), Some(2));
|
||||
assert_eq!(parse_int_attr("colspan='3'", "colspan"), Some(3));
|
||||
assert_eq!(parse_int_attr(" colspan=\"4\"", "rowspan"), None);
|
||||
assert_eq!(parse_int_attr(" class=\"foo\"", "colspan"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_structure_rowspan_pushes_next_row_right() {
|
||||
// <tr><td rowspan="2">A</td><td>B</td></tr><tr><td>C</td></tr>
|
||||
// Expected: A at (0,0), B at (0,1), C at (1,1) — col 0 of row 1
|
||||
// is occupied by A's rowspan.
|
||||
let tokens: Vec<String> = vec![
|
||||
"<table>",
|
||||
"<tbody>",
|
||||
"<tr>",
|
||||
"<td",
|
||||
" rowspan=\"2\"",
|
||||
">",
|
||||
"</td>",
|
||||
"<td></td>",
|
||||
"</tr>",
|
||||
"<tr>",
|
||||
"<td></td>",
|
||||
"</tr>",
|
||||
"</tbody>",
|
||||
"</table>",
|
||||
]
|
||||
.into_iter()
|
||||
.map(t)
|
||||
.collect();
|
||||
|
||||
let slots = parse_structure(&tokens);
|
||||
assert_eq!(slots.len(), 3);
|
||||
assert_eq!((slots[0].row, slots[0].col), (0, 0));
|
||||
assert_eq!(slots[0].rowspan, 2);
|
||||
assert_eq!((slots[1].row, slots[1].col), (0, 1));
|
||||
// C should be at (1, 1) because (1, 0) is occupied by A's rowspan.
|
||||
assert_eq!((slots[2].row, slots[2].col), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_structure_thead_marks_headers() {
|
||||
// <thead><tr><th>H1</th><th>H2</th></tr></thead>
|
||||
// <tbody><tr><td>D1</td><td>D2</td></tr></tbody>
|
||||
let tokens: Vec<String> = vec![
|
||||
"<table>",
|
||||
"<thead>",
|
||||
"<tr>",
|
||||
"<th></th>",
|
||||
"<th></th>",
|
||||
"</tr>",
|
||||
"</thead>",
|
||||
"<tbody>",
|
||||
"<tr>",
|
||||
"<td></td>",
|
||||
"<td></td>",
|
||||
"</tr>",
|
||||
"</tbody>",
|
||||
"</table>",
|
||||
]
|
||||
.into_iter()
|
||||
.map(t)
|
||||
.collect();
|
||||
|
||||
let slots = parse_structure(&tokens);
|
||||
assert_eq!(slots.len(), 4);
|
||||
assert!(slots[0].is_header && slots[1].is_header);
|
||||
assert!(!slots[2].is_header && !slots[3].is_header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_structure_th_outside_thead_still_header() {
|
||||
// A row-header style: leading <th> in tbody.
|
||||
let tokens: Vec<String> = vec![
|
||||
"<table>",
|
||||
"<tbody>",
|
||||
"<tr>",
|
||||
"<th></th>",
|
||||
"<td></td>",
|
||||
"</tr>",
|
||||
"</tbody>",
|
||||
"</table>",
|
||||
]
|
||||
.into_iter()
|
||||
.map(t)
|
||||
.collect();
|
||||
|
||||
let slots = parse_structure(&tokens);
|
||||
assert_eq!(slots.len(), 2);
|
||||
assert!(slots[0].is_header);
|
||||
assert!(!slots[1].is_header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_structure_th_with_attrs() {
|
||||
let tokens: Vec<String> = vec![
|
||||
"<table>",
|
||||
"<thead>",
|
||||
"<tr>",
|
||||
"<th",
|
||||
" colspan=\"2\"",
|
||||
">",
|
||||
"</th>",
|
||||
"</tr>",
|
||||
"</thead>",
|
||||
"</table>",
|
||||
]
|
||||
.into_iter()
|
||||
.map(t)
|
||||
.collect();
|
||||
let slots = parse_structure(&tokens);
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(slots[0].colspan, 2);
|
||||
assert!(slots[0].is_header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_to_markdown_synthetic_3x3() {
|
||||
// Build the cells the parser would produce for the synthetic grid,
|
||||
// and provide some sample text so we can sanity-check output.
|
||||
let cells = vec![
|
||||
StructuredCell {
|
||||
row: 0,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 4,
|
||||
is_header: false,
|
||||
text: "Title".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "a".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 1,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "b".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 2,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "c".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 1,
|
||||
col: 3,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "d".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
];
|
||||
|
||||
let md = cells_to_markdown(&cells);
|
||||
// Header row contains the spanning cell text in col 0 and pads to 4 cols.
|
||||
// Absorbed-by-colspan positions render as empty cells (no padding).
|
||||
assert!(md.starts_with("|Title||||\n"), "got: {md}");
|
||||
assert!(md.contains("|---|---|---|---|\n"));
|
||||
assert!(md.contains("|a|b|c|d|\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_to_markdown_escapes_pipes() {
|
||||
let cells = vec![
|
||||
StructuredCell {
|
||||
row: 0,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "a|b".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
StructuredCell {
|
||||
row: 0,
|
||||
col: 1,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "x".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
];
|
||||
let md = cells_to_markdown(&cells);
|
||||
assert!(md.contains("|a\\|b|x|"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_to_markdown_collapses_whitespace_and_newlines() {
|
||||
let cells = vec![StructuredCell {
|
||||
row: 0,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header: false,
|
||||
text: "foo \n bar\tbaz".into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
}];
|
||||
let md = cells_to_markdown(&cells);
|
||||
assert!(md.contains("|foo bar baz|"));
|
||||
}
|
||||
|
||||
fn cell(row: usize, col: usize, is_header: bool, text: &str) -> StructuredCell {
|
||||
StructuredCell {
|
||||
row,
|
||||
col,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
is_header,
|
||||
text: text.into(),
|
||||
page_pt_bbox: [0.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_to_markdown_separator_after_last_header_row() {
|
||||
// Two-row header (a multi-row thead), then two body rows. Separator
|
||||
// should land after row 1 (the LAST header row), not after row 0.
|
||||
let cells = vec![
|
||||
cell(0, 0, true, "H0a"),
|
||||
cell(0, 1, true, "H0b"),
|
||||
cell(1, 0, true, "H1a"),
|
||||
cell(1, 1, true, "H1b"),
|
||||
cell(2, 0, false, "d0a"),
|
||||
cell(2, 1, false, "d0b"),
|
||||
cell(3, 0, false, "d1a"),
|
||||
cell(3, 1, false, "d1b"),
|
||||
];
|
||||
let md = cells_to_markdown(&cells);
|
||||
let expected = "|H0a|H0b|\n|H1a|H1b|\n|---|---|\n|d0a|d0b|\n|d1a|d1b|\n";
|
||||
assert_eq!(md, expected, "got: {md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_to_markdown_separator_when_row_0_not_header() {
|
||||
// Row 0 is not flagged as a header but row 1 is. Separator should
|
||||
// follow row 1 (the header), demonstrating that we don't blindly
|
||||
// emit after row 0.
|
||||
let cells = vec![
|
||||
cell(0, 0, false, "x0a"),
|
||||
cell(0, 1, false, "x0b"),
|
||||
cell(1, 0, true, "Hdr1"),
|
||||
cell(1, 1, true, "Hdr2"),
|
||||
cell(2, 0, false, "data1"),
|
||||
cell(2, 1, false, "data2"),
|
||||
];
|
||||
let md = cells_to_markdown(&cells);
|
||||
// Confirm the separator is NOT after row 0.
|
||||
assert!(!md.starts_with("|x0a|x0b|\n|---|"), "got: {md}");
|
||||
// Confirm it IS after row 1.
|
||||
assert!(
|
||||
md.contains("|Hdr1|Hdr2|\n|---|---|\n|data1|data2|"),
|
||||
"got: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_to_markdown_no_headers_falls_back_to_row_0() {
|
||||
// No header cells at all — fallback: separator after row 0 so the
|
||||
// output is still a valid markdown pipe-table.
|
||||
let cells = vec![
|
||||
cell(0, 0, false, "a"),
|
||||
cell(0, 1, false, "b"),
|
||||
cell(1, 0, false, "c"),
|
||||
cell(1, 1, false, "d"),
|
||||
];
|
||||
let md = cells_to_markdown(&cells);
|
||||
assert_eq!(md, "|a|b|\n|---|---|\n|c|d|\n");
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+5
-1337
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user