Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9059c3a2ad | ||
|
|
fb7f289b3d | ||
|
|
b4c34ba4b7 | ||
|
|
462a7fdfde | ||
|
|
9abcbbb359 |
@@ -35,3 +35,8 @@ scripts/
|
|||||||
# Test output
|
# Test output
|
||||||
test_output/
|
test_output/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ text = pdf_inspector.extract_text("document.pdf")
|
|||||||
items = pdf_inspector.extract_text_with_positions("document.pdf")
|
items = pdf_inspector.extract_text_with_positions("document.pdf")
|
||||||
for item in items[:5]:
|
for item in items[:5]:
|
||||||
print(f"'{item.text}' at ({item.x:.0f}, {item.y:.0f}) size={item.font_size}")
|
print(f"'{item.text}' at ({item.x:.0f}, {item.y:.0f}) size={item.font_size}")
|
||||||
|
|
||||||
|
# Per-page markdown (one Markdown string per page, plus layout metadata)
|
||||||
|
result = pdf_inspector.extract_pages_markdown("document.pdf")
|
||||||
|
for page in result.pages:
|
||||||
|
print(f"Page {page.page}: {len(page.markdown)} chars, needs_ocr={page.needs_ocr}")
|
||||||
|
|
||||||
|
# Restrict to specific 0-indexed pages (preserves caller order)
|
||||||
|
result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
|
||||||
```
|
```
|
||||||
|
|
||||||
## API reference
|
## API reference
|
||||||
@@ -60,6 +68,8 @@ for item in items[:5]:
|
|||||||
| `extract_text_with_positions_bytes(data, pages=None)` | Text with positions from bytes |
|
| `extract_text_with_positions_bytes(data, pages=None)` | Text with positions from bytes |
|
||||||
| `extract_text_in_regions(path, page_regions)` | Extract text in bounding-box regions |
|
| `extract_text_in_regions(path, page_regions)` | Extract text in bounding-box regions |
|
||||||
| `extract_text_in_regions_bytes(data, page_regions)` | Region extraction from bytes |
|
| `extract_text_in_regions_bytes(data, page_regions)` | Region extraction from bytes |
|
||||||
|
| `extract_pages_markdown(path, pages=None)` | Per-page Markdown + layout metadata (all pages by default) |
|
||||||
|
| `extract_pages_markdown_bytes(data, pages=None)` | Per-page Markdown from bytes |
|
||||||
|
|
||||||
## Types
|
## Types
|
||||||
|
|
||||||
@@ -72,3 +82,7 @@ for item in items[:5]:
|
|||||||
**`RegionText` fields:** `text`, `needs_ocr`
|
**`RegionText` fields:** `text`, `needs_ocr`
|
||||||
|
|
||||||
**`PageRegionTexts` fields:** `page` (0-indexed), `regions` (list of RegionText)
|
**`PageRegionTexts` fields:** `page` (0-indexed), `regions` (list of RegionText)
|
||||||
|
|
||||||
|
**`PageMarkdown` fields:** `page` (0-indexed), `markdown`, `needs_ocr`
|
||||||
|
|
||||||
|
**`PagesExtractionResult` fields:** `pages` (list of PageMarkdown), `pages_with_tables` (1-indexed), `pages_with_columns` (1-indexed), `pages_needing_ocr` (1-indexed), `is_complex`
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ let bytes = std::fs::read("document.pdf")?;
|
|||||||
let result = process_pdf_mem(&bytes)?;
|
let result = process_pdf_mem(&bytes)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Extract per-page Markdown (one string per page, plus document-wide layout
|
||||||
|
metadata):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pdf_inspector::extract_pages_markdown;
|
||||||
|
|
||||||
|
// Pass `None` for every page in document order, or a slice of 0-indexed
|
||||||
|
// pages to restrict the output (caller-supplied order is preserved).
|
||||||
|
let result = extract_pages_markdown("document.pdf", None)?;
|
||||||
|
|
||||||
|
for page in &result.pages {
|
||||||
|
if page.needs_ocr {
|
||||||
|
// Route this page to OCR
|
||||||
|
} else {
|
||||||
|
println!("Page {}: {}", page.page, page.markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Complex layout? {}", result.is_complex);
|
||||||
|
```
|
||||||
|
|
||||||
## Processing modes
|
## Processing modes
|
||||||
|
|
||||||
| Mode | What it does | Returns |
|
| Mode | What it does | Returns |
|
||||||
@@ -102,6 +123,8 @@ let result = process_pdf_mem(&bytes)?;
|
|||||||
| `to_markdown(text, options)` | Convert plain text to Markdown |
|
| `to_markdown(text, options)` | Convert plain text to Markdown |
|
||||||
| `to_markdown_from_items(items, options)` | Markdown from pre-extracted `TextItem`s |
|
| `to_markdown_from_items(items, options)` | Markdown from pre-extracted `TextItem`s |
|
||||||
| `to_markdown_from_items_with_rects(items, options, rects)` | Markdown with rectangle-based table detection |
|
| `to_markdown_from_items_with_rects(items, options, rects)` | Markdown with rectangle-based table detection |
|
||||||
|
| `extract_pages_markdown(path, pages)` | Per-page Markdown + layout metadata (file) |
|
||||||
|
| `extract_pages_markdown_mem(bytes, pages)` | Per-page Markdown from bytes |
|
||||||
|
|
||||||
Low-level detection functions are also available via the `detector` module (`detect_pdf_type`, `detect_pdf_type_with_config`, etc.) for callers who need `PdfTypeResult` instead of `PdfProcessResult`.
|
Low-level detection functions are also available via the `detector` module (`detect_pdf_type`, `detect_pdf_type_with_config`, etc.) for callers who need `PdfTypeResult` instead of `PdfProcessResult`.
|
||||||
|
|
||||||
@@ -119,4 +142,6 @@ Low-level detection functions are also available via the `detector` module (`det
|
|||||||
| `LayoutComplexity` | Layout analysis: is_complex, pages_with_tables, pages_with_columns |
|
| `LayoutComplexity` | Layout analysis: is_complex, pages_with_tables, pages_with_columns |
|
||||||
| `TextItem` | Text with position, font info, and page number |
|
| `TextItem` | Text with position, font info, and page number |
|
||||||
| `MarkdownOptions` | Configuration for Markdown formatting (page numbers, etc.) |
|
| `MarkdownOptions` | Configuration for Markdown formatting (page numbers, etc.) |
|
||||||
|
| `PageMarkdown` | Per-page result: page (0-indexed), markdown, needs_ocr |
|
||||||
|
| `PagesExtractionResult` | Per-page output + 1-indexed pages_with_tables / pages_with_columns / pages_needing_ocr, is_complex |
|
||||||
| `PdfError` | `Io`, `Parse`, `Encrypted`, `InvalidStructure`, `NotAPdf` |
|
| `PdfError` | `Io`, `Parse`, `Encrypted`, `InvalidStructure`, `NotAPdf` |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.3.0",
|
"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.",
|
"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",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
+10
-5
@@ -343,21 +343,26 @@ pub struct PagesExtractionResult {
|
|||||||
pub is_complex: bool,
|
pub is_complex: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract formatted markdown for specific pages of a PDF, with layout
|
/// Extract formatted markdown for pages of a PDF, with layout classification
|
||||||
/// classification metadata.
|
/// metadata.
|
||||||
///
|
///
|
||||||
/// Returns per-page markdown and classification data (tables, columns,
|
/// Returns per-page markdown and classification data (tables, columns,
|
||||||
/// OCR needs) from a single parse. Font statistics are computed from the
|
/// OCR needs) from a single parse. Font statistics are computed from the
|
||||||
/// full document so header detection is consistent across pages.
|
/// full document so header detection is consistent across pages.
|
||||||
|
///
|
||||||
|
/// Omit `pages` (or pass `undefined`) to return every page in document
|
||||||
|
/// order. Pass an array of 0-indexed page numbers to restrict output to
|
||||||
|
/// those pages, in caller-supplied order.
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn extract_pages_markdown(
|
pub fn extract_pages_markdown(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
pages: Vec<u32>,
|
pages: Option<Vec<u32>>,
|
||||||
) -> Result<PagesExtractionResult> {
|
) -> Result<PagesExtractionResult> {
|
||||||
let bytes: Vec<u8> = buffer.to_vec();
|
let bytes: Vec<u8> = buffer.to_vec();
|
||||||
catch_panic("extract_pages_markdown", move || {
|
catch_panic("extract_pages_markdown", move || {
|
||||||
let result = pdf_inspector::extract_pages_markdown_mem(&bytes, &pages)
|
let result =
|
||||||
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
|
pdf_inspector::extract_pages_markdown_mem(&bytes, pages.as_deref())
|
||||||
|
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
|
||||||
Ok(PagesExtractionResult {
|
Ok(PagesExtractionResult {
|
||||||
pages: result
|
pages: result
|
||||||
.pages
|
.pages
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
extractText,
|
extractText,
|
||||||
extractTextWithPositions,
|
extractTextWithPositions,
|
||||||
extractTextInRegions,
|
extractTextInRegions,
|
||||||
|
extractPagesMarkdown,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
const fixture = readFileSync('../tests/fixtures/thermo-freon12.pdf');
|
const fixture = readFileSync('../tests/fixtures/thermo-freon12.pdf');
|
||||||
@@ -89,6 +90,28 @@ assert.equal(typeof regionResults[0].regions[0].text, 'string');
|
|||||||
assert.equal(typeof regionResults[0].regions[0].needsOcr, 'boolean');
|
assert.equal(typeof regionResults[0].regions[0].needsOcr, 'boolean');
|
||||||
console.log(' extractTextInRegions: OK');
|
console.log(' extractTextInRegions: OK');
|
||||||
|
|
||||||
|
// --- extractPagesMarkdown ---
|
||||||
|
console.log('Testing extractPagesMarkdown...');
|
||||||
|
|
||||||
|
// omit pages → every page in document order
|
||||||
|
const allPages = extractPagesMarkdown(fixture);
|
||||||
|
assert.equal(allPages.pages.length, 3);
|
||||||
|
assert.deepEqual(allPages.pages.map(p => p.page), [0, 1, 2]);
|
||||||
|
assert.ok(typeof allPages.pages[0].markdown === 'string');
|
||||||
|
assert.equal(typeof allPages.pages[0].needsOcr, 'boolean');
|
||||||
|
assert.ok(Array.isArray(allPages.pagesWithTables));
|
||||||
|
assert.ok(Array.isArray(allPages.pagesWithColumns));
|
||||||
|
assert.ok(Array.isArray(allPages.pagesNeedingOcr));
|
||||||
|
assert.equal(typeof allPages.isComplex, 'boolean');
|
||||||
|
console.log(' extractPagesMarkdown (no pages arg): OK');
|
||||||
|
|
||||||
|
// selected pages preserve caller order
|
||||||
|
const picked = extractPagesMarkdown(fixture, [2, 0]);
|
||||||
|
assert.equal(picked.pages.length, 2);
|
||||||
|
assert.equal(picked.pages[0].page, 2);
|
||||||
|
assert.equal(picked.pages[1].page, 0);
|
||||||
|
console.log(' extractPagesMarkdown with pages: OK');
|
||||||
|
|
||||||
// --- Error handling ---
|
// --- Error handling ---
|
||||||
console.log('Testing error handling...');
|
console.log('Testing error handling...');
|
||||||
assert.throws(() => processPdf(Buffer.from('not a pdf')), /process_pdf/);
|
assert.throws(() => processPdf(Buffer.from('not a pdf')), /process_pdf/);
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ class PageRegionTexts:
|
|||||||
"""0-indexed page number."""
|
"""0-indexed page number."""
|
||||||
regions: list[RegionText]
|
regions: list[RegionText]
|
||||||
|
|
||||||
|
class PageMarkdown:
|
||||||
|
"""Per-page markdown extraction result."""
|
||||||
|
page: int
|
||||||
|
"""0-indexed page number."""
|
||||||
|
markdown: str
|
||||||
|
"""Formatted markdown for this page (empty string when needs_ocr is True)."""
|
||||||
|
needs_ocr: bool
|
||||||
|
"""True when text on this page is unreliable and OCR should be used instead."""
|
||||||
|
|
||||||
|
class PagesExtractionResult:
|
||||||
|
"""Per-page markdown output with document-wide layout classification."""
|
||||||
|
pages: list[PageMarkdown]
|
||||||
|
"""Per-page markdown results, in the order requested."""
|
||||||
|
pages_with_tables: list[int]
|
||||||
|
"""1-indexed pages where tables were detected."""
|
||||||
|
pages_with_columns: list[int]
|
||||||
|
"""1-indexed pages where multi-column layout was detected."""
|
||||||
|
pages_needing_ocr: list[int]
|
||||||
|
"""1-indexed pages that need OCR."""
|
||||||
|
is_complex: bool
|
||||||
|
"""True if any page has tables or multi-column layout."""
|
||||||
|
|
||||||
def process_pdf(path: str, pages: Optional[list[int]] = None) -> PdfResult:
|
def process_pdf(path: str, pages: Optional[list[int]] = None) -> PdfResult:
|
||||||
"""Process a PDF: detect type, extract text, convert to Markdown."""
|
"""Process a PDF: detect type, extract text, convert to Markdown."""
|
||||||
...
|
...
|
||||||
@@ -115,3 +137,31 @@ def extract_text_in_regions_bytes(
|
|||||||
page_regions: List of (page_0indexed, [[x1, y1, x2, y2], ...]) tuples.
|
page_regions: List of (page_0indexed, [[x1, y1, x2, y2], ...]) tuples.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def extract_pages_markdown(
|
||||||
|
path: str,
|
||||||
|
pages: Optional[list[int]] = None,
|
||||||
|
) -> PagesExtractionResult:
|
||||||
|
"""Extract formatted markdown for pages of a PDF, with layout classification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the PDF file.
|
||||||
|
pages: Optional list of 0-indexed pages. When ``None`` (default), every
|
||||||
|
page is returned in document order. Otherwise, output matches the
|
||||||
|
caller-supplied order.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PagesExtractionResult with per-page markdown and document-wide layout
|
||||||
|
classification (tables, columns, OCR needs).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def extract_pages_markdown_bytes(
|
||||||
|
data: bytes,
|
||||||
|
pages: Optional[list[int]] = None,
|
||||||
|
) -> PagesExtractionResult:
|
||||||
|
"""Extract formatted markdown for pages of a PDF from bytes.
|
||||||
|
|
||||||
|
See :func:`extract_pages_markdown` for details.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|||||||
+42
-7
@@ -336,13 +336,17 @@ pub struct PagesExtractionResult {
|
|||||||
pub is_complex: bool,
|
pub is_complex: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract formatted markdown for specific pages of a PDF, with layout
|
/// Extract formatted markdown for pages of a PDF, with layout
|
||||||
/// classification metadata.
|
/// classification metadata.
|
||||||
///
|
///
|
||||||
/// Unlike [`process_pdf_mem`] which returns one concatenated markdown string,
|
/// Unlike [`process_pdf_mem`] which returns one concatenated markdown string,
|
||||||
/// this returns per-page markdown so callers can mix direct extraction
|
/// this returns per-page markdown so callers can mix direct extraction
|
||||||
/// (for simple text pages) with GPU OCR (for complex/scanned pages).
|
/// (for simple text pages) with GPU OCR (for complex/scanned pages).
|
||||||
///
|
///
|
||||||
|
/// When `pages` is `None`, every page (0-indexed, in document order) is
|
||||||
|
/// returned. When `Some(&[...])`, only the listed 0-indexed pages are
|
||||||
|
/// returned, in the caller's order.
|
||||||
|
///
|
||||||
/// Font statistics are computed from the full document so header
|
/// Font statistics are computed from the full document so header
|
||||||
/// detection thresholds are consistent regardless of which pages are
|
/// detection thresholds are consistent regardless of which pages are
|
||||||
/// requested. Per-page `needs_ocr` is set when the page has GID-encoded
|
/// requested. Per-page `needs_ocr` is set when the page has GID-encoded
|
||||||
@@ -352,7 +356,7 @@ pub struct PagesExtractionResult {
|
|||||||
/// at near-zero cost since the items/rects/lines are already in memory.
|
/// at near-zero cost since the items/rects/lines are already in memory.
|
||||||
pub fn extract_pages_markdown_mem(
|
pub fn extract_pages_markdown_mem(
|
||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
pages: &[u32],
|
pages: Option<&[u32]>,
|
||||||
) -> Result<PagesExtractionResult, PdfError> {
|
) -> Result<PagesExtractionResult, PdfError> {
|
||||||
validate_pdf_bytes(buffer)?;
|
validate_pdf_bytes(buffer)?;
|
||||||
let (doc, page_count) = load_document_from_mem(buffer)?;
|
let (doc, page_count) = load_document_from_mem(buffer)?;
|
||||||
@@ -368,10 +372,20 @@ pub fn extract_pages_markdown_mem(
|
|||||||
// Compute font stats from full document (cross-page consistency).
|
// Compute font stats from full document (cross-page consistency).
|
||||||
let font_stats = markdown::analysis::calculate_font_stats_from_items(&all_items);
|
let font_stats = markdown::analysis::calculate_font_stats_from_items(&all_items);
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(pages.len());
|
// When caller doesn't specify pages, return every page in document order.
|
||||||
|
let all_pages: Vec<u32>;
|
||||||
|
let pages_slice: &[u32] = match pages {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
all_pages = (0..page_count).collect();
|
||||||
|
&all_pages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::with_capacity(pages_slice.len());
|
||||||
let mut pages_needing_ocr = Vec::new();
|
let mut pages_needing_ocr = Vec::new();
|
||||||
|
|
||||||
for &page_0idx in pages {
|
for &page_0idx in pages_slice {
|
||||||
// Out-of-range pages → empty + needs_ocr
|
// Out-of-range pages → empty + needs_ocr
|
||||||
if page_0idx >= page_count {
|
if page_0idx >= page_count {
|
||||||
pages_needing_ocr.push(page_0idx + 1);
|
pages_needing_ocr.push(page_0idx + 1);
|
||||||
@@ -444,6 +458,20 @@ pub fn extract_pages_markdown_mem(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path-based wrapper for [`extract_pages_markdown_mem`].
|
||||||
|
///
|
||||||
|
/// Reads the PDF from disk and extracts per-page markdown. Pass `None` for
|
||||||
|
/// `pages` to return every page in document order, or `Some(&[...])` to
|
||||||
|
/// restrict to specific 0-indexed pages (in caller-supplied order).
|
||||||
|
pub fn extract_pages_markdown<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
pages: Option<&[u32]>,
|
||||||
|
) -> Result<PagesExtractionResult, PdfError> {
|
||||||
|
validate_pdf_file(&path)?;
|
||||||
|
let buffer = std::fs::read(path.as_ref())?;
|
||||||
|
extract_pages_markdown_mem(&buffer, pages)
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Region-based text extraction (for hybrid OCR pipelines)
|
// Region-based text extraction (for hybrid OCR pipelines)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1782,19 +1810,26 @@ fn compute_layout_complexity(
|
|||||||
markdown::filter_lines_to_band(lines, page, x_lo, x_hi)
|
markdown::filter_lines_to_band(lines, page, x_lo, x_hi)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TOC pages route through the table detector but render as flat
|
||||||
|
// lists. They aren't tables in any user-facing sense, so don't
|
||||||
|
// count them toward LayoutComplexity (would also trip the
|
||||||
|
// table-page guard in column detection below).
|
||||||
|
let has_data_table =
|
||||||
|
|tables: &[tables::Table]| tables.iter().any(|t| t.kind == tables::TableKind::Data);
|
||||||
|
|
||||||
let (rect_tables, _) = tables::detect_tables_from_rects(&band_items, &band_rects, page);
|
let (rect_tables, _) = tables::detect_tables_from_rects(&band_items, &band_rects, page);
|
||||||
if !rect_tables.is_empty() {
|
if has_data_table(&rect_tables) {
|
||||||
found_table = true;
|
found_table = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let line_tables = tables::detect_tables_from_lines(&band_items, &band_lines, page);
|
let line_tables = tables::detect_tables_from_lines(&band_items, &band_lines, page);
|
||||||
if !line_tables.is_empty() {
|
if has_data_table(&line_tables) {
|
||||||
found_table = true;
|
found_table = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Heuristic fallback for borderless tables
|
// Heuristic fallback for borderless tables
|
||||||
let heuristic_tables = tables::detect_tables(&band_items, base_size, false);
|
let heuristic_tables = tables::detect_tables(&band_items, base_size, false);
|
||||||
if !heuristic_tables.is_empty() {
|
if has_data_table(&heuristic_tables) {
|
||||||
found_table = true;
|
found_table = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+121
@@ -146,6 +146,67 @@ impl PyPageRegionTexts {
|
|||||||
// Text item wrapper
|
// Text item wrapper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Per-page markdown extraction result.
|
||||||
|
#[pyclass(name = "PageMarkdown")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PyPageMarkdown {
|
||||||
|
/// 0-indexed page number.
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub page: u32,
|
||||||
|
/// Formatted markdown for this page.
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub markdown: String,
|
||||||
|
/// True when text on this page is unreliable (GID-encoded fonts,
|
||||||
|
/// encoding issues, garbage text, or empty extraction).
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub needs_ocr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyPageMarkdown {
|
||||||
|
fn __repr__(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"PageMarkdown(page={}, markdown='{}', needs_ocr={})",
|
||||||
|
self.page,
|
||||||
|
self.markdown.chars().take(40).collect::<String>(),
|
||||||
|
self.needs_ocr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined per-page markdown extraction and layout classification result.
|
||||||
|
#[pyclass(name = "PagesExtractionResult")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PyPagesExtractionResult {
|
||||||
|
/// Per-page markdown results, in the order requested.
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub pages: Vec<PyPageMarkdown>,
|
||||||
|
/// 1-indexed pages where tables were detected.
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub pages_with_tables: Vec<u32>,
|
||||||
|
/// 1-indexed pages where multi-column layout was detected.
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub pages_with_columns: Vec<u32>,
|
||||||
|
/// 1-indexed pages that need OCR (scanned/image-based or unreliable text).
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub pages_needing_ocr: Vec<u32>,
|
||||||
|
/// True if any page has tables or columns.
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub is_complex: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyPagesExtractionResult {
|
||||||
|
fn __repr__(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"PagesExtractionResult(pages={}, pages_with_tables={:?}, is_complex={})",
|
||||||
|
self.pages.len(),
|
||||||
|
self.pages_with_tables,
|
||||||
|
self.is_complex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A positioned text item extracted from a PDF.
|
/// A positioned text item extracted from a PDF.
|
||||||
#[pyclass(name = "TextItem")]
|
#[pyclass(name = "TextItem")]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -280,6 +341,24 @@ fn parse_page_regions(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to_py_pages_result(r: crate::PagesExtractionResult) -> PyPagesExtractionResult {
|
||||||
|
PyPagesExtractionResult {
|
||||||
|
pages: r
|
||||||
|
.pages
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| PyPageMarkdown {
|
||||||
|
page: p.page,
|
||||||
|
markdown: p.markdown,
|
||||||
|
needs_ocr: p.needs_ocr,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
pages_with_tables: r.pages_with_tables,
|
||||||
|
pages_with_columns: r.pages_with_columns,
|
||||||
|
pages_needing_ocr: r.pages_needing_ocr,
|
||||||
|
is_complex: r.is_complex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn convert_region_results(results: Vec<crate::PageRegionResult>) -> Vec<PyPageRegionTexts> {
|
fn convert_region_results(results: Vec<crate::PageRegionResult>) -> Vec<PyPageRegionTexts> {
|
||||||
results
|
results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -442,6 +521,44 @@ fn extract_text_in_regions_bytes(
|
|||||||
Ok(convert_region_results(results))
|
Ok(convert_region_results(results))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract formatted markdown for pages of a PDF file, with layout
|
||||||
|
/// classification metadata.
|
||||||
|
///
|
||||||
|
/// Returns per-page markdown and classification data (tables, columns,
|
||||||
|
/// OCR needs) from a single parse. Font statistics are computed from the
|
||||||
|
/// full document so header detection is consistent across pages.
|
||||||
|
///
|
||||||
|
/// Args:
|
||||||
|
/// path: Path to the PDF file.
|
||||||
|
/// pages: Optional list of 0-indexed pages. When None (default), every
|
||||||
|
/// page is returned in document order. When provided, output
|
||||||
|
/// matches the caller-supplied order.
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// PagesExtractionResult with per-page markdown and classification data.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (path, pages=None))]
|
||||||
|
fn extract_pages_markdown(
|
||||||
|
path: &str,
|
||||||
|
pages: Option<Vec<u32>>,
|
||||||
|
) -> PyResult<PyPagesExtractionResult> {
|
||||||
|
let result = crate::extract_pages_markdown(path, pages.as_deref()).map_err(to_py_err)?;
|
||||||
|
Ok(to_py_pages_result(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract formatted markdown for pages of a PDF from bytes.
|
||||||
|
///
|
||||||
|
/// See [`extract_pages_markdown`] for details.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (data, pages=None))]
|
||||||
|
fn extract_pages_markdown_bytes(
|
||||||
|
data: &[u8],
|
||||||
|
pages: Option<Vec<u32>>,
|
||||||
|
) -> PyResult<PyPagesExtractionResult> {
|
||||||
|
let result = crate::extract_pages_markdown_mem(data, pages.as_deref()).map_err(to_py_err)?;
|
||||||
|
Ok(to_py_pages_result(result))
|
||||||
|
}
|
||||||
|
|
||||||
/// Python module definition.
|
/// Python module definition.
|
||||||
#[pymodule]
|
#[pymodule]
|
||||||
fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
@@ -450,6 +567,8 @@ fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<PyTextItem>()?;
|
m.add_class::<PyTextItem>()?;
|
||||||
m.add_class::<PyRegionText>()?;
|
m.add_class::<PyRegionText>()?;
|
||||||
m.add_class::<PyPageRegionTexts>()?;
|
m.add_class::<PyPageRegionTexts>()?;
|
||||||
|
m.add_class::<PyPageMarkdown>()?;
|
||||||
|
m.add_class::<PyPagesExtractionResult>()?;
|
||||||
m.add_function(wrap_pyfunction!(process_pdf, m)?)?;
|
m.add_function(wrap_pyfunction!(process_pdf, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(process_pdf_bytes, m)?)?;
|
m.add_function(wrap_pyfunction!(process_pdf_bytes, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(detect_pdf, m)?)?;
|
m.add_function(wrap_pyfunction!(detect_pdf, m)?)?;
|
||||||
@@ -462,5 +581,7 @@ fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_function(wrap_pyfunction!(extract_text_with_positions_bytes, m)?)?;
|
m.add_function(wrap_pyfunction!(extract_text_with_positions_bytes, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(extract_text_in_regions, m)?)?;
|
m.add_function(wrap_pyfunction!(extract_text_in_regions, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(extract_text_in_regions_bytes, m)?)?;
|
m.add_function(wrap_pyfunction!(extract_text_in_regions_bytes, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(extract_pages_markdown, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(extract_pages_markdown_bytes, m)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -581,8 +581,14 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
|
|||||||
// Validation 1: some rows should have content in first column.
|
// Validation 1: some rows should have content in first column.
|
||||||
// Use a lower threshold (25%) for tables with wrapped cells where
|
// Use a lower threshold (25%) for tables with wrapped cells where
|
||||||
// continuation lines leave the first column empty.
|
// continuation lines leave the first column empty.
|
||||||
|
// Skip when cells form a narrow TOC pattern: hierarchical entries indented
|
||||||
|
// across multiple X levels leave the leftmost column sparse (only top-level
|
||||||
|
// chapters land there) but the structure is still a valid TOC. Narrow only
|
||||||
|
// (<=5 cols) — wide multi-column TOCs (e.g. 2-up indices) would render
|
||||||
|
// poorly through format_toc_as_list, which assumes one entry per row.
|
||||||
let rows_with_first_col = cells.iter().filter(|row| !row[0].is_empty()).count();
|
let rows_with_first_col = cells.iter().filter(|row| !row[0].is_empty()).count();
|
||||||
if rows_with_first_col < rows.len() / 4 {
|
let is_narrow_toc = columns.len() <= 5 && is_table_of_contents(&cells);
|
||||||
|
if rows_with_first_col < rows.len() / 4 && !is_narrow_toc {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
" validation 1 fail: {}/{} rows have first col",
|
" validation 1 fail: {}/{} rows have first col",
|
||||||
rows_with_first_col,
|
rows_with_first_col,
|
||||||
@@ -653,8 +659,12 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation 8: Reject paragraph-like content falsely detected as tables
|
// Validation 8: Reject paragraph-like content falsely detected as tables.
|
||||||
if is_paragraph_content(&cells) {
|
// TOC pages with deep indentation (top-level chapters in col 0, subsections
|
||||||
|
// in cols 1-3, page numbers in last col) leave most cells empty and trip
|
||||||
|
// the paragraph heuristic; TOC shape is a safer signal here. Narrow only
|
||||||
|
// — see narrow-TOC rationale at validation 1.
|
||||||
|
if is_paragraph_content(&cells) && !is_narrow_toc {
|
||||||
log::debug!(" validation 9 fail: paragraph content");
|
log::debug!(" validation 9 fail: paragraph content");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -677,12 +687,7 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
|
|||||||
item_indices.len()
|
item_indices.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(Table {
|
Some(Table::new(columns, rows, cells, item_indices))
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
cells,
|
|
||||||
item_indices,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this looks like a key-value pair layout rather than a table
|
/// Check if this looks like a key-value pair layout rather than a table
|
||||||
@@ -906,7 +911,7 @@ fn looks_like_number(s: &str) -> bool {
|
|||||||
/// Check if this looks like a Table of Contents (either style).
|
/// Check if this looks like a Table of Contents (either style).
|
||||||
///
|
///
|
||||||
/// Used by format.rs to render TOCs as flat lists instead of markdown tables.
|
/// Used by format.rs to render TOCs as flat lists instead of markdown tables.
|
||||||
pub(super) fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
|
pub fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
|
||||||
is_dot_leader_toc(cells) || is_tabular_toc(cells)
|
is_dot_leader_toc(cells) || is_tabular_toc(cells)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1601,6 +1606,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_table_of_contents_accepts_hierarchical_indented_toc() {
|
||||||
|
// Mythos system card pages 4-5: top-level chapters indent at col 0,
|
||||||
|
// subsections at cols 1-2, leaving col 0 mostly empty (only ~10% of
|
||||||
|
// rows). Validation 1 was rejecting these even though the structure
|
||||||
|
// is unambiguously a TOC.
|
||||||
|
let cells = vec![
|
||||||
|
vec!["Abstract".to_string(), String::new(), "3".to_string()],
|
||||||
|
vec![
|
||||||
|
"1 Introduction".to_string(),
|
||||||
|
String::new(),
|
||||||
|
"10".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
String::new(),
|
||||||
|
"1.1 Model training".to_string(),
|
||||||
|
"11".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
String::new(),
|
||||||
|
"1.1.1 Training data".to_string(),
|
||||||
|
"11".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
String::new(),
|
||||||
|
"1.1.2 Crowd workers".to_string(),
|
||||||
|
"12".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
String::new(),
|
||||||
|
"1.2 Release decision".to_string(),
|
||||||
|
"13".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
"2 RSP evaluations".to_string(),
|
||||||
|
String::new(),
|
||||||
|
"16".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
String::new(),
|
||||||
|
"2.1 RSP risk assessment".to_string(),
|
||||||
|
"16".to_string(),
|
||||||
|
],
|
||||||
|
vec![String::new(), "2.1.1 Context".to_string(), "16".to_string()],
|
||||||
|
vec![
|
||||||
|
String::new(),
|
||||||
|
"2.2 CB evaluations".to_string(),
|
||||||
|
"20".to_string(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
assert!(
|
||||||
|
is_table_of_contents(&cells),
|
||||||
|
"hierarchical TOC with sparse col 0 should still be detected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_table_of_contents_rejects_dotless_toc() {
|
fn is_table_of_contents_rejects_dotless_toc() {
|
||||||
// Tabular TOC without leader dots: first column starts with dotted
|
// Tabular TOC without leader dots: first column starts with dotted
|
||||||
|
|||||||
@@ -265,12 +265,12 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
|
|||||||
page, num_rows, num_cols, item_indices.len(), page_item_count, non_empty_rows, cols_with_content
|
page, num_rows, num_cols, item_indices.len(), page_item_count, non_empty_rows, cols_with_content
|
||||||
);
|
);
|
||||||
|
|
||||||
vec![Table {
|
vec![Table::new(
|
||||||
columns: col_edges,
|
col_edges,
|
||||||
rows: row_edges_desc[..num_rows].to_vec(),
|
row_edges_desc[..num_rows].to_vec(),
|
||||||
cells,
|
cells,
|
||||||
item_indices,
|
item_indices,
|
||||||
}]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1037,12 +1037,7 @@ fn try_build_grid(
|
|||||||
(columns, cells)
|
(columns, cells)
|
||||||
};
|
};
|
||||||
|
|
||||||
GridResult::Ok(Table {
|
GridResult::Ok(Table::new(columns, rows, cells, item_indices))
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
cells,
|
|
||||||
item_indices,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
|
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
|
||||||
@@ -1442,12 +1437,7 @@ fn detect_row_stripe_table(
|
|||||||
content_ratio * 100.0
|
content_ratio * 100.0
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(Table {
|
Some(Table::new(column_centers, row_centers, cells, item_indices))
|
||||||
columns: column_centers,
|
|
||||||
rows: row_centers,
|
|
||||||
cells,
|
|
||||||
item_indices,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect a table from cell-background rects that failed grid detection.
|
/// Detect a table from cell-background rects that failed grid detection.
|
||||||
@@ -1691,12 +1681,7 @@ fn detect_row_stripe_table_from_cell_rects(
|
|||||||
non_empty_cells as f32 / total_cells * 100.0
|
non_empty_cells as f32 / total_cells * 100.0
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(Table {
|
Some(Table::new(column_centers, row_centers, cells, item_indices))
|
||||||
columns: column_centers,
|
|
||||||
rows: row_centers,
|
|
||||||
cells,
|
|
||||||
item_indices,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect a table by merging all cluster rects into one group.
|
/// Detect a table by merging all cluster rects into one group.
|
||||||
@@ -1875,12 +1860,7 @@ fn detect_merged_cluster_table(
|
|||||||
content_ratio * 100.0
|
content_ratio * 100.0
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(Table {
|
Some(Table::new(column_centers, row_centers, cells, item_indices))
|
||||||
columns: column_centers,
|
|
||||||
rows: row_centers,
|
|
||||||
cells,
|
|
||||||
item_indices,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cluster text item X positions into column centers with a given minimum threshold.
|
/// Cluster text item X positions into column centers with a given minimum threshold.
|
||||||
|
|||||||
@@ -188,12 +188,12 @@ pub fn detect_tables_from_struct_tree(
|
|||||||
all_item_indices.sort_unstable();
|
all_item_indices.sort_unstable();
|
||||||
all_item_indices.dedup();
|
all_item_indices.dedup();
|
||||||
|
|
||||||
tables.push(Table {
|
tables.push(Table::new(
|
||||||
columns: col_positions,
|
col_positions,
|
||||||
rows: row_positions,
|
row_positions,
|
||||||
cells,
|
cells,
|
||||||
item_indices: all_item_indices,
|
all_item_indices,
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
tables
|
tables
|
||||||
|
|||||||
+21
-21
@@ -1,26 +1,19 @@
|
|||||||
//! Table-to-markdown formatting and cell cleanup.
|
//! Table-to-markdown formatting and cell cleanup.
|
||||||
|
|
||||||
use super::detect_heuristic::is_table_of_contents;
|
use super::{Table, TableKind};
|
||||||
use super::Table;
|
|
||||||
|
|
||||||
pub fn table_to_markdown(table: &Table) -> String {
|
pub fn table_to_markdown(table: &Table) -> String {
|
||||||
if table.cells.is_empty() || table.cells[0].is_empty() {
|
if table.cells.is_empty() || table.cells[0].is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect TOC on the raw cells: clean_table_cells merges rows in ways
|
// TOCs render poorly as markdown tables — emit a flat per-row text list
|
||||||
// that can make genuine data tables superficially resemble a TOC
|
// instead so the page numbers stay aligned with their section titles
|
||||||
// (short numeric cells, few columns) — but the raw detection here
|
// rather than drifting to a separate column. Format from raw cells
|
||||||
// preserves the original multi-column structure and only matches the
|
// because continuation-row merging in clean_table_cells collapses
|
||||||
// true TOC pattern.
|
// separate TOC entries (e.g. "6.2 Contamination" + "6.2.1 SWE-bench")
|
||||||
//
|
// into one line where sub-entries leave column 0 empty.
|
||||||
// Tables of contents render poorly as markdown tables — emit a flat
|
if table.kind == TableKind::Toc {
|
||||||
// per-row text list instead so the page numbers stay aligned with
|
|
||||||
// their section titles rather than drifting to a separate column.
|
|
||||||
// Format from raw cells: continuation-row merging collapses separate
|
|
||||||
// TOC entries (e.g. "6.2 Contamination" + "6.2.1 SWE-bench") into a
|
|
||||||
// single line because sub-entries leave column 0 empty.
|
|
||||||
if is_table_of_contents(&table.cells) {
|
|
||||||
return format_toc_as_list(&table.cells, &[]);
|
return format_toc_as_list(&table.cells, &[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +463,7 @@ mod tests {
|
|||||||
vec!["Bob".into(), "25".into()],
|
vec!["Bob".into(), "25".into()],
|
||||||
],
|
],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
assert!(md.contains("|Name|"));
|
assert!(md.contains("|Name|"));
|
||||||
@@ -485,6 +479,7 @@ mod tests {
|
|||||||
rows: vec![500.0],
|
rows: vec![500.0],
|
||||||
cells: vec![vec!["Only".into(), "Row".into()]],
|
cells: vec![vec!["Only".into(), "Row".into()]],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
assert!(md.contains("|Only|"));
|
assert!(md.contains("|Only|"));
|
||||||
@@ -498,6 +493,7 @@ mod tests {
|
|||||||
rows: vec![],
|
rows: vec![],
|
||||||
cells: vec![],
|
cells: vec![],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
assert_eq!(table_to_markdown(&table), "");
|
assert_eq!(table_to_markdown(&table), "");
|
||||||
}
|
}
|
||||||
@@ -513,6 +509,7 @@ mod tests {
|
|||||||
vec!["(1)".into(), "Footnote text".into()],
|
vec!["(1)".into(), "Footnote text".into()],
|
||||||
],
|
],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
assert!(md.contains("(1) Footnote text"));
|
assert!(md.contains("(1) Footnote text"));
|
||||||
@@ -528,6 +525,7 @@ mod tests {
|
|||||||
vec!["太郎".into(), "25".into()],
|
vec!["太郎".into(), "25".into()],
|
||||||
],
|
],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
assert!(md.contains("名前"));
|
assert!(md.contains("名前"));
|
||||||
@@ -541,6 +539,7 @@ mod tests {
|
|||||||
rows: vec![500.0],
|
rows: vec![500.0],
|
||||||
cells: vec![vec![]],
|
cells: vec![vec![]],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
assert_eq!(table_to_markdown(&table), "");
|
assert_eq!(table_to_markdown(&table), "");
|
||||||
}
|
}
|
||||||
@@ -550,10 +549,10 @@ mod tests {
|
|||||||
// A TOC-shaped table with section numbers in col 0 and page numbers
|
// A TOC-shaped table with section numbers in col 0 and page numbers
|
||||||
// in the last column should render as a flat list, not a markdown
|
// in the last column should render as a flat list, not a markdown
|
||||||
// table, so the page numbers stay on the same line as their titles.
|
// table, so the page numbers stay on the same line as their titles.
|
||||||
let table = Table {
|
let table = Table::new(
|
||||||
columns: vec![50.0, 80.0, 300.0],
|
vec![50.0, 80.0, 300.0],
|
||||||
rows: vec![500.0; 5],
|
vec![500.0; 5],
|
||||||
cells: vec![
|
vec![
|
||||||
vec![
|
vec![
|
||||||
"4.3".into(),
|
"4.3".into(),
|
||||||
"Case studies and targeted evaluations".into(),
|
"Case studies and targeted evaluations".into(),
|
||||||
@@ -572,8 +571,9 @@ mod tests {
|
|||||||
vec!["4.4".into(), "Capability evaluations".into(), "101".into()],
|
vec!["4.4".into(), "Capability evaluations".into(), "101".into()],
|
||||||
vec!["4.5".into(), "White-box analyses".into(), "113".into()],
|
vec!["4.5".into(), "White-box analyses".into(), "113".into()],
|
||||||
],
|
],
|
||||||
item_indices: vec![],
|
vec![],
|
||||||
};
|
);
|
||||||
|
assert_eq!(table.kind, TableKind::Toc);
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
assert!(
|
assert!(
|
||||||
!md.contains("|---|"),
|
!md.contains("|---|"),
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ pub(crate) fn recover_header_row(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::tables::TableKind;
|
||||||
use crate::types::ItemType;
|
use crate::types::ItemType;
|
||||||
|
|
||||||
fn make_item(text: &str, x: f32, y: f32, font_size: f32) -> TextItem {
|
fn make_item(text: &str, x: f32, y: f32, font_size: f32) -> TextItem {
|
||||||
@@ -756,6 +757,7 @@ mod tests {
|
|||||||
rows: vec![500.0, 480.0],
|
rows: vec![500.0, 480.0],
|
||||||
cells: vec![vec!["A".into(), "B".into()], vec!["C".into(), "D".into()]],
|
cells: vec![vec!["A".into(), "B".into()], vec!["C".into(), "D".into()]],
|
||||||
item_indices: vec![2, 3],
|
item_indices: vec![2, 3],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
recover_header_row(&mut table, &all_items, 9.0);
|
recover_header_row(&mut table, &all_items, 9.0);
|
||||||
@@ -774,6 +776,7 @@ mod tests {
|
|||||||
rows: vec![500.0],
|
rows: vec![500.0],
|
||||||
cells: vec![vec!["A".into(), "B".into()]],
|
cells: vec![vec!["A".into(), "B".into()]],
|
||||||
item_indices: vec![0, 1],
|
item_indices: vec![0, 1],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows_before = table.rows.len();
|
let rows_before = table.rows.len();
|
||||||
@@ -794,6 +797,7 @@ mod tests {
|
|||||||
rows: vec![500.0, 480.0],
|
rows: vec![500.0, 480.0],
|
||||||
cells: vec![vec!["A".into(), "B".into()], vec!["C".into(), "D".into()]],
|
cells: vec![vec!["A".into(), "B".into()], vec!["C".into(), "D".into()]],
|
||||||
item_indices: vec![2, 3],
|
item_indices: vec![2, 3],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows_before = table.rows.len();
|
let rows_before = table.rows.len();
|
||||||
@@ -814,6 +818,7 @@ mod tests {
|
|||||||
rows: vec![500.0],
|
rows: vec![500.0],
|
||||||
cells: vec![vec!["A".into(), "B".into()]],
|
cells: vec![vec!["A".into(), "B".into()]],
|
||||||
item_indices: vec![1, 2],
|
item_indices: vec![1, 2],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows_before = table.rows.len();
|
let rows_before = table.rows.len();
|
||||||
@@ -829,6 +834,7 @@ mod tests {
|
|||||||
rows: vec![],
|
rows: vec![],
|
||||||
cells: vec![],
|
cells: vec![],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
recover_header_row(&mut table, &all_items, 9.0);
|
recover_header_row(&mut table, &all_items, 9.0);
|
||||||
|
|||||||
+49
-11
@@ -11,6 +11,7 @@ mod format;
|
|||||||
mod grid;
|
mod grid;
|
||||||
|
|
||||||
pub use detect_heuristic::detect_tables;
|
pub use detect_heuristic::detect_tables;
|
||||||
|
pub(crate) use detect_heuristic::is_table_of_contents;
|
||||||
pub use detect_lines::detect_tables_from_lines;
|
pub use detect_lines::detect_tables_from_lines;
|
||||||
pub(crate) use detect_rects::cluster_rects;
|
pub(crate) use detect_rects::cluster_rects;
|
||||||
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
|
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
|
||||||
@@ -166,12 +167,12 @@ pub(crate) fn try_build_rect_guided_table(
|
|||||||
used_indices.sort_unstable();
|
used_indices.sort_unstable();
|
||||||
used_indices.dedup();
|
used_indices.dedup();
|
||||||
|
|
||||||
Some(Table {
|
Some(Table::new(
|
||||||
columns: col_boundaries,
|
col_boundaries,
|
||||||
rows: row_boundaries,
|
row_boundaries,
|
||||||
cells,
|
cells,
|
||||||
item_indices: used_indices,
|
used_indices,
|
||||||
})
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a TextItem whose text contains multiple whitespace-separated tokens
|
/// Split a TextItem whose text contains multiple whitespace-separated tokens
|
||||||
@@ -541,12 +542,22 @@ pub(crate) fn try_build_table_from_columns(items: &[TextItem], page: u32) -> Opt
|
|||||||
multi_col_rows
|
multi_col_rows
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(Table {
|
Some(Table::new(col_xs, row_ys, cells, item_indices))
|
||||||
columns: col_xs,
|
}
|
||||||
rows: row_ys,
|
|
||||||
cells,
|
/// What kind of structure a detected `Table` represents. Classification is
|
||||||
item_indices,
|
/// computed once at construction so consumers don't have to re-analyze the
|
||||||
})
|
/// cells (and stay consistent across detection backends).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum TableKind {
|
||||||
|
/// A real data table — renders as markdown table syntax.
|
||||||
|
#[default]
|
||||||
|
Data,
|
||||||
|
/// A table of contents — renders as a flat list with tab-aligned page
|
||||||
|
/// numbers via `format_toc_as_list`. Detected through the table pipeline
|
||||||
|
/// because TOCs share row/column structure with tables, but they are not
|
||||||
|
/// data tables and shouldn't appear in `pages_with_tables` etc.
|
||||||
|
Toc,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A detected table.
|
/// A detected table.
|
||||||
@@ -560,6 +571,31 @@ pub struct Table {
|
|||||||
pub cells: Vec<Vec<String>>,
|
pub cells: Vec<Vec<String>>,
|
||||||
/// Items that belong to this table
|
/// Items that belong to this table
|
||||||
pub item_indices: Vec<usize>,
|
pub item_indices: Vec<usize>,
|
||||||
|
/// Data table vs TOC. Set by `Table::new` from `cells`.
|
||||||
|
pub kind: TableKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
/// Build a table and classify it (data vs TOC) from its cells.
|
||||||
|
pub fn new(
|
||||||
|
columns: Vec<f32>,
|
||||||
|
rows: Vec<f32>,
|
||||||
|
cells: Vec<Vec<String>>,
|
||||||
|
item_indices: Vec<usize>,
|
||||||
|
) -> Self {
|
||||||
|
let kind = if is_table_of_contents(&cells) {
|
||||||
|
TableKind::Toc
|
||||||
|
} else {
|
||||||
|
TableKind::Data
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
cells,
|
||||||
|
item_indices,
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -642,6 +678,7 @@ mod tests {
|
|||||||
vec!["Cell 1".into(), "Cell 2".into()],
|
vec!["Cell 1".into(), "Cell 2".into()],
|
||||||
],
|
],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
@@ -1002,6 +1039,7 @@ mod tests {
|
|||||||
vec!["3".into(), "5/2".into(), "Item C".into(), "300".into()],
|
vec!["3".into(), "5/2".into(), "Item C".into(), "300".into()],
|
||||||
],
|
],
|
||||||
item_indices: vec![],
|
item_indices: vec![],
|
||||||
|
kind: TableKind::Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let md = table_to_markdown(&table);
|
let md = table_to_markdown(&table);
|
||||||
|
|||||||
+53
-15
@@ -4,10 +4,10 @@ use pdf_inspector::detector::{DetectionConfig, ScanStrategy};
|
|||||||
use pdf_inspector::extractor::group_into_lines;
|
use pdf_inspector::extractor::group_into_lines;
|
||||||
use pdf_inspector::types::TextLine;
|
use pdf_inspector::types::TextLine;
|
||||||
use pdf_inspector::{
|
use pdf_inspector::{
|
||||||
detect_pdf_type, extract_pages_markdown_mem, extract_tables_in_regions_mem, extract_text,
|
detect_pdf_type, extract_pages_markdown, extract_pages_markdown_mem,
|
||||||
extract_text_in_regions_mem, extract_text_with_positions, process_pdf_mem,
|
extract_tables_in_regions_mem, extract_text, extract_text_in_regions_mem,
|
||||||
process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType,
|
extract_text_with_positions, process_pdf_mem, process_pdf_with_options, to_markdown,
|
||||||
TextItem,
|
MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -1481,7 +1481,7 @@ fn test_bits_pilani_page8_table_detection() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_basic() {
|
fn test_extract_pages_markdown_basic() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let result = extract_pages_markdown_mem(&buf, &[0, 1]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[0, 1])).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.pages.len(), 2);
|
assert_eq!(result.pages.len(), 2);
|
||||||
assert_eq!(result.pages[0].page, 0);
|
assert_eq!(result.pages[0].page, 0);
|
||||||
@@ -1495,7 +1495,7 @@ fn test_extract_pages_markdown_basic() {
|
|||||||
fn test_extract_pages_markdown_page_ordering() {
|
fn test_extract_pages_markdown_page_ordering() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
// Request pages in non-sequential order
|
// Request pages in non-sequential order
|
||||||
let result = extract_pages_markdown_mem(&buf, &[1, 0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[1, 0])).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.pages.len(), 2);
|
assert_eq!(result.pages.len(), 2);
|
||||||
// Results should match input order, not document order
|
// Results should match input order, not document order
|
||||||
@@ -1506,7 +1506,7 @@ fn test_extract_pages_markdown_page_ordering() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_out_of_range() {
|
fn test_extract_pages_markdown_out_of_range() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let result = extract_pages_markdown_mem(&buf, &[9999]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[9999])).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.pages.len(), 1);
|
assert_eq!(result.pages.len(), 1);
|
||||||
assert_eq!(result.pages[0].page, 9999);
|
assert_eq!(result.pages[0].page, 9999);
|
||||||
@@ -1518,14 +1518,14 @@ fn test_extract_pages_markdown_out_of_range() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_empty_pages_list() {
|
fn test_extract_pages_markdown_empty_pages_list() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let result = extract_pages_markdown_mem(&buf, &[]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[])).unwrap();
|
||||||
assert!(result.pages.is_empty());
|
assert!(result.pages.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_single_page() {
|
fn test_extract_pages_markdown_single_page() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let result = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[0])).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.pages.len(), 1);
|
assert_eq!(result.pages.len(), 1);
|
||||||
assert_eq!(result.pages[0].page, 0);
|
assert_eq!(result.pages[0].page, 0);
|
||||||
@@ -1535,7 +1535,7 @@ fn test_extract_pages_markdown_single_page() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_invalid_buffer() {
|
fn test_extract_pages_markdown_invalid_buffer() {
|
||||||
let result = extract_pages_markdown_mem(b"not a pdf", &[0]);
|
let result = extract_pages_markdown_mem(b"not a pdf", Some(&[0]));
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,7 +1543,7 @@ fn test_extract_pages_markdown_invalid_buffer() {
|
|||||||
fn test_extract_pages_markdown_gid_pages_need_ocr() {
|
fn test_extract_pages_markdown_gid_pages_need_ocr() {
|
||||||
// shinagawa_identity_h.pdf has GID-encoded fonts
|
// shinagawa_identity_h.pdf has GID-encoded fonts
|
||||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||||
let result = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[0])).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.pages.len(), 1);
|
assert_eq!(result.pages.len(), 1);
|
||||||
assert!(result.pages[0].needs_ocr);
|
assert!(result.pages[0].needs_ocr);
|
||||||
@@ -1556,7 +1556,7 @@ fn test_extract_pages_markdown_classification_with_tables() {
|
|||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let page_count = process_pdf_mem(&buf).unwrap().page_count;
|
let page_count = process_pdf_mem(&buf).unwrap().page_count;
|
||||||
let page_indices: Vec<u32> = (0..page_count).collect();
|
let page_indices: Vec<u32> = (0..page_count).collect();
|
||||||
let result = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&page_indices)).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!result.pages_with_tables.is_empty(),
|
!result.pages_with_tables.is_empty(),
|
||||||
@@ -1569,7 +1569,7 @@ fn test_extract_pages_markdown_classification_with_tables() {
|
|||||||
fn test_extract_pages_markdown_simple_pdf_no_complexity() {
|
fn test_extract_pages_markdown_simple_pdf_no_complexity() {
|
||||||
// bare_name_struct.pdf is a simple document with a heading and code block
|
// bare_name_struct.pdf is a simple document with a heading and code block
|
||||||
let buf = std::fs::read("tests/fixtures/bare_name_struct.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/bare_name_struct.pdf").unwrap();
|
||||||
let result = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&[0])).unwrap();
|
||||||
|
|
||||||
assert!(result.pages_with_tables.is_empty());
|
assert!(result.pages_with_tables.is_empty());
|
||||||
assert!(result.pages_with_columns.is_empty());
|
assert!(result.pages_with_columns.is_empty());
|
||||||
@@ -1582,7 +1582,7 @@ fn test_extract_pages_markdown_classification_matches_process_pdf() {
|
|||||||
let full = process_pdf_mem(&buf).unwrap();
|
let full = process_pdf_mem(&buf).unwrap();
|
||||||
let page_count = full.page_count;
|
let page_count = full.page_count;
|
||||||
let page_indices: Vec<u32> = (0..page_count).collect();
|
let page_indices: Vec<u32> = (0..page_count).collect();
|
||||||
let result = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&page_indices)).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.pages_with_tables, full.layout.pages_with_tables,
|
result.pages_with_tables, full.layout.pages_with_tables,
|
||||||
@@ -1605,7 +1605,7 @@ fn test_extract_pages_markdown_consistency_with_process_pdf() {
|
|||||||
// Get per-page output for all pages
|
// Get per-page output for all pages
|
||||||
let page_count = full.page_count;
|
let page_count = full.page_count;
|
||||||
let page_indices: Vec<u32> = (0..page_count).collect();
|
let page_indices: Vec<u32> = (0..page_count).collect();
|
||||||
let result = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
let result = extract_pages_markdown_mem(&buf, Some(&page_indices)).unwrap();
|
||||||
|
|
||||||
// Concatenated per-page markdown should contain substantial overlap with
|
// Concatenated per-page markdown should contain substantial overlap with
|
||||||
// the full output (exact match not expected due to header/footer stripping
|
// the full output (exact match not expected due to header/footer stripping
|
||||||
@@ -1630,3 +1630,41 @@ fn test_extract_pages_markdown_consistency_with_process_pdf() {
|
|||||||
full_md.len()
|
full_md.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pages_markdown_none_returns_all_pages() {
|
||||||
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
|
let page_count = process_pdf_mem(&buf).unwrap().page_count;
|
||||||
|
|
||||||
|
let result = extract_pages_markdown_mem(&buf, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.pages.len() as u32, page_count);
|
||||||
|
for (i, page) in result.pages.iter().enumerate() {
|
||||||
|
assert_eq!(page.page, i as u32, "pages should be in document order");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pages_markdown_path_api() {
|
||||||
|
let path = "tests/fixtures/nexo-price-en.pdf";
|
||||||
|
let buf = std::fs::read(path).unwrap();
|
||||||
|
|
||||||
|
let via_path = extract_pages_markdown(path, Some(&[0])).unwrap();
|
||||||
|
let via_mem = extract_pages_markdown_mem(&buf, Some(&[0])).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(via_path.pages.len(), via_mem.pages.len());
|
||||||
|
assert_eq!(via_path.pages[0].markdown, via_mem.pages[0].markdown);
|
||||||
|
assert_eq!(via_path.pages[0].needs_ocr, via_mem.pages[0].needs_ocr);
|
||||||
|
assert_eq!(via_path.is_complex, via_mem.is_complex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pages_markdown_path_none_returns_all_pages() {
|
||||||
|
let path = "tests/fixtures/nexo-price-en.pdf";
|
||||||
|
let page_count = process_pdf_mem(&std::fs::read(path).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.page_count;
|
||||||
|
|
||||||
|
let result = extract_pages_markdown(path, None).unwrap();
|
||||||
|
assert_eq!(result.pages.len() as u32, page_count);
|
||||||
|
}
|
||||||
|
|||||||
@@ -270,6 +270,78 @@ class TestExtractTextInRegions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# extract_pages_markdown / extract_pages_markdown_bytes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractPagesMarkdown:
|
||||||
|
def test_default_returns_all_pages(self):
|
||||||
|
result = pdf_inspector.extract_pages_markdown(
|
||||||
|
fixture_path("thermo-freon12.pdf")
|
||||||
|
)
|
||||||
|
assert len(result.pages) == 3
|
||||||
|
assert [p.page for p in result.pages] == [0, 1, 2]
|
||||||
|
assert all(isinstance(p.markdown, str) for p in result.pages)
|
||||||
|
|
||||||
|
def test_bytes_default_returns_all_pages(self):
|
||||||
|
data = fixture_bytes("thermo-freon12.pdf")
|
||||||
|
result = pdf_inspector.extract_pages_markdown_bytes(data)
|
||||||
|
assert len(result.pages) == 3
|
||||||
|
|
||||||
|
def test_selected_pages_preserve_order(self):
|
||||||
|
result = pdf_inspector.extract_pages_markdown(
|
||||||
|
fixture_path("thermo-freon12.pdf"), pages=[2, 0]
|
||||||
|
)
|
||||||
|
assert [p.page for p in result.pages] == [2, 0]
|
||||||
|
|
||||||
|
def test_bytes_selected_pages_preserve_order(self):
|
||||||
|
data = fixture_bytes("thermo-freon12.pdf")
|
||||||
|
result = pdf_inspector.extract_pages_markdown_bytes(data, pages=[1])
|
||||||
|
assert len(result.pages) == 1
|
||||||
|
assert result.pages[0].page == 1
|
||||||
|
|
||||||
|
def test_page_fields(self):
|
||||||
|
result = pdf_inspector.extract_pages_markdown(
|
||||||
|
fixture_path("thermo-freon12.pdf"), pages=[0]
|
||||||
|
)
|
||||||
|
page = result.pages[0]
|
||||||
|
assert isinstance(page.page, int)
|
||||||
|
assert isinstance(page.markdown, str)
|
||||||
|
assert isinstance(page.needs_ocr, bool)
|
||||||
|
assert not page.needs_ocr # text-based fixture
|
||||||
|
assert len(page.markdown) > 0
|
||||||
|
|
||||||
|
def test_result_fields(self):
|
||||||
|
result = pdf_inspector.extract_pages_markdown(
|
||||||
|
fixture_path("thermo-freon12.pdf")
|
||||||
|
)
|
||||||
|
assert isinstance(result.pages, list)
|
||||||
|
assert isinstance(result.pages_with_tables, list)
|
||||||
|
assert isinstance(result.pages_with_columns, list)
|
||||||
|
assert isinstance(result.pages_needing_ocr, list)
|
||||||
|
assert isinstance(result.is_complex, bool)
|
||||||
|
|
||||||
|
def test_out_of_range_page_marks_needs_ocr(self):
|
||||||
|
result = pdf_inspector.extract_pages_markdown(
|
||||||
|
fixture_path("thermo-freon12.pdf"), pages=[9999]
|
||||||
|
)
|
||||||
|
assert len(result.pages) == 1
|
||||||
|
assert result.pages[0].needs_ocr
|
||||||
|
assert result.pages[0].markdown == ""
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
result = pdf_inspector.extract_pages_markdown(
|
||||||
|
fixture_path("thermo-freon12.pdf"), pages=[0]
|
||||||
|
)
|
||||||
|
assert "PagesExtractionResult" in repr(result)
|
||||||
|
assert "PageMarkdown" in repr(result.pages[0])
|
||||||
|
|
||||||
|
def test_not_a_pdf(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pdf_inspector.extract_pages_markdown_bytes(b"not a pdf")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Error handling
|
# Error handling
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user