Compare commits
+1
-3
@@ -17,7 +17,7 @@ crate-type = ["lib", "cdylib"]
|
||||
pyo3 = { version = "0.25", features = ["extension-module"], optional = true }
|
||||
|
||||
# PDF parsing
|
||||
lopdf = { git = "https://github.com/J-F-Liu/lopdf", rev = "052674053814a9f4897af94f0b8e46a545c9b329", features = ["rayon"] }
|
||||
lopdf = { git = "https://github.com/J-F-Liu/lopdf", rev = "7a05512d831415b1f2b1ce522391d6beab8a1284", features = ["rayon"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
@@ -55,5 +55,3 @@ path = "src/bin/detect_pdf.rs"
|
||||
[[bin]]
|
||||
name = "dump_ops"
|
||||
path = "src/bin/dump_ops.rs"
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,23 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
|
||||
- **Single document load** — The document is parsed once and shared between detection and extraction, avoiding redundant I/O.
|
||||
- **Lightweight** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
|
||||
|
||||
## Benchmark
|
||||
|
||||
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only direct text extraction engines are shown — no OCR, no ML models. Scores are 0-1, higher is better.
|
||||
|
||||
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|
||||
|---|---|---|---|---|---|
|
||||
| pdf-inspector | 0.77 | 0.87 | 0.52 | 0.58 | 4s |
|
||||
| opendataloader | 0.84 | 0.91 | 0.49 | 0.74 | 11s |
|
||||
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
|
||||
| markitdown | 0.58 | 0.88 | 0.00 | 0.00 | 8s |
|
||||
|
||||
For context, engines that use OCR/ML (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus.
|
||||
|
||||
**Where we do well:** Speed (fastest of all engines), reading order, table detection vs other direct-text tools.
|
||||
|
||||
**Where we lag:** Heading detection trails opendataloader — many PDFs use bold text at body font size for headings, or headings that are only slightly larger than body text. Table detection trails OCR-based engines that can see visual table structure.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Python
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "firecrawl-pdf-inspector",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.3",
|
||||
"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",
|
||||
|
||||
+106
-63
@@ -3,6 +3,7 @@
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
use std::collections::HashSet;
|
||||
use std::panic;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
@@ -116,6 +117,30 @@ fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
|
||||
Error::new(Status::GenericFailure, format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
/// Run a closure, catching any Rust panic and converting it to a NAPI error.
|
||||
/// Prevents process abort from unwind panics in the native module.
|
||||
fn catch_panic<F, T>(ctx: &str, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce() -> Result<T> + panic::UnwindSafe,
|
||||
{
|
||||
match panic::catch_unwind(f) {
|
||||
Ok(result) => result,
|
||||
Err(payload) => {
|
||||
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
};
|
||||
Err(Error::new(
|
||||
Status::GenericFailure,
|
||||
format!("{ctx}: Rust panic: {msg}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public NAPI API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -123,21 +148,27 @@ fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
|
||||
/// Process a PDF from a Buffer: detect type, extract text, and convert to Markdown.
|
||||
#[napi]
|
||||
pub fn process_pdf(buffer: Buffer, pages: Option<Vec<u32>>) -> Result<PdfResult> {
|
||||
let mut opts = pdf_inspector::PdfOptions::new();
|
||||
if let Some(p) = pages {
|
||||
opts = opts.pages(p);
|
||||
}
|
||||
let result = pdf_inspector::process_pdf_mem_with_options(&buffer, opts)
|
||||
.map_err(|e| to_napi_err(e, "process_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("process_pdf", move || {
|
||||
let mut opts = pdf_inspector::PdfOptions::new();
|
||||
if let Some(p) = pages {
|
||||
opts = opts.pages(p);
|
||||
}
|
||||
let result = pdf_inspector::process_pdf_mem_with_options(&bytes, opts)
|
||||
.map_err(|e| to_napi_err(e, "process_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
})
|
||||
}
|
||||
|
||||
/// Fast detection only — no text extraction or markdown.
|
||||
#[napi]
|
||||
pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
|
||||
let result =
|
||||
pdf_inspector::detect_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "detect_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("detect_pdf", move || {
|
||||
let result =
|
||||
pdf_inspector::detect_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "detect_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
})
|
||||
}
|
||||
|
||||
/// Lightweight PDF classification — returns type, page count, and OCR pages.
|
||||
@@ -145,21 +176,27 @@ pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
|
||||
/// Pages in pagesNeedingOcr are 0-indexed.
|
||||
#[napi]
|
||||
pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> {
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "classify_pdf"))?;
|
||||
|
||||
Ok(PdfClassification {
|
||||
pdf_type: pdf_type_string(result.pdf_type),
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("classify_pdf", move || {
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "classify_pdf"))?;
|
||||
Ok(PdfClassification {
|
||||
pdf_type: pdf_type_string(result.pdf_type),
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract plain text from a PDF Buffer.
|
||||
#[napi]
|
||||
pub fn extract_text(buffer: Buffer) -> Result<String> {
|
||||
pdf_inspector::extractor::extract_text_mem(&buffer).map_err(|e| to_napi_err(e, "extract_text"))
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("extract_text", move || {
|
||||
pdf_inspector::extractor::extract_text_mem(&bytes)
|
||||
.map_err(|e| to_napi_err(e, "extract_text"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract text with position information from a PDF Buffer.
|
||||
@@ -168,35 +205,38 @@ pub fn extract_text_with_positions(
|
||||
buffer: Buffer,
|
||||
pages: Option<Vec<u32>>,
|
||||
) -> Result<Vec<TextItem>> {
|
||||
let items = match pages {
|
||||
Some(p) => {
|
||||
let page_set: HashSet<u32> = p.into_iter().collect();
|
||||
pdf_inspector::extractor::extract_text_with_positions_mem_pages(
|
||||
&buffer,
|
||||
Some(&page_set),
|
||||
)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?
|
||||
}
|
||||
None => pdf_inspector::extractor::extract_text_with_positions_mem(&buffer)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?,
|
||||
};
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("extract_text_with_positions", move || {
|
||||
let items = match pages {
|
||||
Some(p) => {
|
||||
let page_set: HashSet<u32> = p.into_iter().collect();
|
||||
pdf_inspector::extractor::extract_text_with_positions_mem_pages(
|
||||
&bytes,
|
||||
Some(&page_set),
|
||||
)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?
|
||||
}
|
||||
None => pdf_inspector::extractor::extract_text_with_positions_mem(&bytes)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?,
|
||||
};
|
||||
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| TextItem {
|
||||
text: item.text,
|
||||
x: item.x as f64,
|
||||
y: item.y as f64,
|
||||
width: item.width as f64,
|
||||
height: item.height as f64,
|
||||
font: item.font,
|
||||
font_size: item.font_size as f64,
|
||||
page: item.page,
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item_type_string(&item.item_type),
|
||||
})
|
||||
.collect())
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| TextItem {
|
||||
text: item.text,
|
||||
x: item.x as f64,
|
||||
y: item.y as f64,
|
||||
width: item.width as f64,
|
||||
height: item.height as f64,
|
||||
font: item.font,
|
||||
font_size: item.font_size as f64,
|
||||
page: item.page,
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item_type_string(&item.item_type),
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract text within bounding-box regions from a PDF.
|
||||
@@ -214,6 +254,7 @@ pub fn extract_text_in_regions(
|
||||
buffer: Buffer,
|
||||
page_regions: Vec<PageRegions>,
|
||||
) -> Result<Vec<PageRegionTexts>> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions
|
||||
.iter()
|
||||
.map(|pr| {
|
||||
@@ -232,21 +273,23 @@ pub fn extract_text_in_regions(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = pdf_inspector::extract_text_in_regions_mem(&buffer, ®ions)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_in_regions"))?;
|
||||
catch_panic("extract_text_in_regions", move || {
|
||||
let results = pdf_inspector::extract_text_in_regions_mem(&bytes, ®ions)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_in_regions"))?;
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|page_result| PageRegionTexts {
|
||||
page: page_result.page,
|
||||
regions: page_result
|
||||
.regions
|
||||
.into_iter()
|
||||
.map(|r| RegionText {
|
||||
text: r.text,
|
||||
needs_ocr: r.needs_ocr,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|page_result| PageRegionTexts {
|
||||
page: page_result.page,
|
||||
regions: page_result
|
||||
.regions
|
||||
.into_iter()
|
||||
.map(|r| RegionText {
|
||||
text: r.text,
|
||||
needs_ocr: r.needs_ocr,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(crate) fn extract_page_text_items(
|
||||
page_num: u32,
|
||||
font_cmaps: &FontCMaps,
|
||||
include_invisible: bool,
|
||||
) -> Result<(PageExtraction, bool), PdfError> {
|
||||
) -> Result<(PageExtraction, bool, bool), PdfError> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
@@ -182,7 +182,7 @@ pub(crate) fn extract_page_text_items(
|
||||
content.operations.len(),
|
||||
MAX_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false));
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false));
|
||||
}
|
||||
|
||||
// Graphics state tracking
|
||||
@@ -1009,11 +1009,12 @@ pub(crate) fn extract_page_text_items(
|
||||
// Some PDFs embed landscape content in portrait pages using a rotated text
|
||||
// matrix (e.g. [0, b, -b, 0, tx, ty] for 90° CCW). The layout engine
|
||||
// assumes x=horizontal, y=vertical — so we swap coordinates to match.
|
||||
let (items, rects, lines) = correct_rotated_page(items, rects, lines, &rotation_votes);
|
||||
let (items, rects, lines, coords_rotated) =
|
||||
correct_rotated_page(items, rects, lines, &rotation_votes);
|
||||
|
||||
let items = super::merge_text_items(items);
|
||||
let items = super::merge_subscript_items(items);
|
||||
Ok(((items, rects, lines), has_gid_fonts))
|
||||
Ok(((items, rects, lines), has_gid_fonts, coords_rotated))
|
||||
}
|
||||
|
||||
/// Counts of text operators with horizontal vs rotated combined matrices.
|
||||
@@ -1030,9 +1031,9 @@ fn correct_rotated_page(
|
||||
mut rects: Vec<PdfRect>,
|
||||
mut lines: Vec<PdfLine>,
|
||||
votes: &RotationVotes,
|
||||
) -> (Vec<TextItem>, Vec<PdfRect>, Vec<PdfLine>) {
|
||||
) -> (Vec<TextItem>, Vec<PdfRect>, Vec<PdfLine>, bool) {
|
||||
if items.len() < 2 {
|
||||
return (items, rects, lines);
|
||||
return (items, rects, lines, false);
|
||||
}
|
||||
|
||||
// Use the combined-matrix direction votes collected during extraction.
|
||||
@@ -1041,7 +1042,7 @@ fn correct_rotated_page(
|
||||
let total_votes = votes.horizontal + votes.rotated;
|
||||
if total_votes == 0 || votes.rotated * 3 < total_votes * 2 {
|
||||
// Less than ~67% of text operators are rotated → not a rotated page
|
||||
return (items, rects, lines);
|
||||
return (items, rects, lines, false);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
@@ -1092,7 +1093,7 @@ fn correct_rotated_page(
|
||||
line.y2 = new_y2;
|
||||
}
|
||||
|
||||
(items, rects, lines)
|
||||
(items, rects, lines, true)
|
||||
}
|
||||
|
||||
/// Remove near-duplicate rects (same coordinates within 0.5 pt tolerance).
|
||||
@@ -1228,7 +1229,7 @@ mod tests {
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let result = extract_page_text_items(&doc, page_id, 1, &font_cmaps, false).unwrap();
|
||||
let ((items, rects, lines), _has_gid) = result;
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated) = result;
|
||||
assert!(items.is_empty());
|
||||
assert!(rects.is_empty());
|
||||
assert!(lines.is_empty());
|
||||
|
||||
+28
-2
@@ -35,6 +35,7 @@ pub(crate) fn detect_columns(
|
||||
if page_items.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
debug!("page {}: detect_columns: {} items", page, page_items.len());
|
||||
|
||||
// Find page bounds
|
||||
let x_min = page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
|
||||
@@ -166,6 +167,23 @@ pub(crate) fn detect_columns(
|
||||
return vec![ColumnRegion { x_min, x_max }];
|
||||
}
|
||||
|
||||
// Try center-based assignment first (handles asymmetric layouts / sidebars
|
||||
// better than edge-based). Fall back to edge-based if center produces
|
||||
// a degenerate split (one side empty).
|
||||
let result = validate_and_build_columns(
|
||||
&valleys,
|
||||
&page_items,
|
||||
x_min,
|
||||
BIN_WIDTH,
|
||||
x_max,
|
||||
MIN_ITEMS_PER_COLUMN,
|
||||
MIN_VERTICAL_SPAN_RATIO,
|
||||
page,
|
||||
true, // center-based assignment
|
||||
);
|
||||
if result.len() > 1 {
|
||||
return result;
|
||||
}
|
||||
return validate_and_build_columns(
|
||||
&valleys,
|
||||
&page_items,
|
||||
@@ -175,7 +193,7 @@ pub(crate) fn detect_columns(
|
||||
MIN_ITEMS_PER_COLUMN,
|
||||
MIN_VERTICAL_SPAN_RATIO,
|
||||
page,
|
||||
false, // edge-based assignment for absolute valleys
|
||||
false, // edge-based fallback
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,7 +523,15 @@ fn validate_and_build_columns(
|
||||
})
|
||||
.collect();
|
||||
|
||||
if left_items.len() < min_items || right_items.len() < min_items {
|
||||
// Require both sides to have items. Symmetric layout needs min_items
|
||||
// on each side. Asymmetric layouts (sidebars) are accepted when the
|
||||
// dominant side has ≥ min_items and the smaller side has ≥ 3 items.
|
||||
let (smaller, larger) = if left_items.len() <= right_items.len() {
|
||||
(left_items.len(), right_items.len())
|
||||
} else {
|
||||
(right_items.len(), left_items.len())
|
||||
};
|
||||
if larger < min_items || smaller < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ fn extract_positioned_text_impl(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let ((mut items, rects, lines), has_gid_fonts) =
|
||||
let ((mut items, rects, lines), has_gid_fonts, _coords_rotated) =
|
||||
extract_page_text_items(doc, page_id, *page_num, font_cmaps, include_invisible)?;
|
||||
if has_gid_fonts {
|
||||
gid_encoded_pages.insert(*page_num);
|
||||
|
||||
+142
-74
@@ -344,16 +344,22 @@ pub fn extract_text_in_regions_mem(
|
||||
) -> Result<Vec<PageRegionResult>, PdfError> {
|
||||
validate_pdf_bytes(buffer)?;
|
||||
let (doc, _page_count) = load_document_from_mem(buffer)?;
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let pages = doc.get_pages();
|
||||
|
||||
// Build a set of pages we need to extract
|
||||
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect(); // to 1-indexed
|
||||
// Build a set of pages we need to extract (1-indexed for lopdf)
|
||||
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
|
||||
|
||||
// Fast mode: skip expensive TrueType font fallback parsing.
|
||||
// Fonts that can't be decoded from ToUnicode alone will produce empty/garbage
|
||||
// text, triggering needs_ocr=true → GPU OCR fallback in the pipeline.
|
||||
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
|
||||
|
||||
// Extract text items for needed pages only
|
||||
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
|
||||
let mut page_heights: HashMap<u32, f32> = HashMap::new();
|
||||
let mut gid_pages: HashSet<u32> = HashSet::new();
|
||||
let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
|
||||
let mut rotated_pages: HashSet<u32> = HashSet::new();
|
||||
|
||||
for (page_num, &page_id) in pages.iter() {
|
||||
if !needed_pages.contains(page_num) {
|
||||
@@ -365,7 +371,7 @@ pub fn extract_text_in_regions_mem(
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
// Extract text items for this page
|
||||
let ((mut items, _rects, _lines), has_gid) =
|
||||
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -373,10 +379,16 @@ pub fn extract_text_in_regions_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
)?;
|
||||
text_utils::fix_letterspaced_items(&mut items);
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
page_thresholds.insert(*page_num, threshold);
|
||||
}
|
||||
if has_gid {
|
||||
gid_pages.insert(*page_num);
|
||||
}
|
||||
if coords_rotated {
|
||||
rotated_pages.insert(*page_num);
|
||||
}
|
||||
items_by_page.insert(*page_num, items);
|
||||
}
|
||||
|
||||
@@ -388,6 +400,12 @@ pub fn extract_text_in_regions_mem(
|
||||
let items = items_by_page.get(&page_1idx);
|
||||
let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
|
||||
let page_has_gid = gid_pages.contains(&page_1idx);
|
||||
let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
|
||||
let coords = if rotated_pages.contains(&page_1idx) {
|
||||
RegionCoordSpace::Rotated90Ccw
|
||||
} else {
|
||||
RegionCoordSpace::Standard
|
||||
};
|
||||
|
||||
let mut page_results = Vec::with_capacity(regions.len());
|
||||
|
||||
@@ -395,13 +413,23 @@ pub fn extract_text_in_regions_mem(
|
||||
let [rx1, ry1, rx2, ry2] = *rect;
|
||||
|
||||
let text = match items {
|
||||
Some(items) => collect_text_in_region(items, rx1, ry1, rx2, ry2, page_h),
|
||||
Some(items) => collect_text_in_region_with_options(
|
||||
items,
|
||||
rx1,
|
||||
ry1,
|
||||
rx2,
|
||||
ry2,
|
||||
page_h,
|
||||
coords,
|
||||
adaptive_threshold,
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let needs_ocr = text.trim().is_empty()
|
||||
|| page_has_gid
|
||||
|| is_garbage_text(&text)
|
||||
|| is_cid_garbage(&text)
|
||||
|| detect_encoding_issues(&text);
|
||||
|
||||
page_results.push(RegionText { text, needs_ocr });
|
||||
@@ -449,9 +477,23 @@ fn obj_to_f32(obj: &lopdf::Object) -> Option<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RegionCoordSpace {
|
||||
Standard,
|
||||
Rotated90Ccw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RegionBounds {
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
}
|
||||
|
||||
/// Collect text items that fall within a region bbox (top-left origin, PDF points)
|
||||
/// and return them as a single string in reading order.
|
||||
fn collect_text_in_region(
|
||||
pub fn collect_text_in_region(
|
||||
items: &[TextItem],
|
||||
rx1: f32,
|
||||
ry1: f32,
|
||||
@@ -459,83 +501,109 @@ fn collect_text_in_region(
|
||||
ry2: f32,
|
||||
page_height: f32,
|
||||
) -> String {
|
||||
// Convert region from top-left to bottom-left origin
|
||||
let by1 = page_height - ry2; // top-left y2 → bottom-left y1
|
||||
let by2 = page_height - ry1; // top-left y1 → bottom-left y2
|
||||
collect_text_in_region_with_options(
|
||||
items,
|
||||
rx1,
|
||||
ry1,
|
||||
rx2,
|
||||
ry2,
|
||||
page_height,
|
||||
infer_region_coord_space(items),
|
||||
0.10,
|
||||
)
|
||||
}
|
||||
|
||||
// Collect items whose center falls within the region
|
||||
let mut matched: Vec<&TextItem> = items
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_text_in_region_with_options(
|
||||
items: &[TextItem],
|
||||
rx1: f32,
|
||||
ry1: f32,
|
||||
rx2: f32,
|
||||
ry2: f32,
|
||||
page_height: f32,
|
||||
coord_space: RegionCoordSpace,
|
||||
adaptive_threshold: f32,
|
||||
) -> String {
|
||||
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
|
||||
let Some(page) = items.first().map(|item| item.page) else {
|
||||
return String::new();
|
||||
};
|
||||
let matched: Vec<TextItem> = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let cx = item.x + item.width / 2.0;
|
||||
let cy = item.y + item.height / 2.0;
|
||||
cx >= rx1 && cx <= rx2 && cy >= by1 && cy <= by2
|
||||
})
|
||||
.filter(|item| region_overlaps_item(item, bounds))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if matched.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Sort top→bottom (descending Y in bottom-left coords), then left→right.
|
||||
// Uses total_cmp to avoid panics on NaN values from bogus font metrics.
|
||||
matched.sort_by(|a, b| {
|
||||
let fs_a = if a.font_size.is_finite() {
|
||||
a.font_size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fs_b = if b.font_size.is_finite() {
|
||||
b.font_size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let line_threshold = fs_a.max(fs_b) * 0.5;
|
||||
let ay = if a.y.is_finite() { a.y } else { 0.0 };
|
||||
let by = if b.y.is_finite() { b.y } else { 0.0 };
|
||||
let y_diff = by - ay; // descending Y = top to bottom
|
||||
if y_diff.abs() < line_threshold {
|
||||
let ax = if a.x.is_finite() { a.x } else { 0.0 };
|
||||
let bx = if b.x.is_finite() { b.x } else { 0.0 };
|
||||
ax.total_cmp(&bx)
|
||||
} else {
|
||||
by.total_cmp(&ay)
|
||||
}
|
||||
});
|
||||
|
||||
// Group into lines and join
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut last_y = f32::NAN;
|
||||
let mut last_x_end = 0.0_f32;
|
||||
|
||||
for item in &matched {
|
||||
let line_threshold = item.font_size * 0.5;
|
||||
let same_line = (item.y - last_y).abs() < line_threshold;
|
||||
|
||||
if !same_line && !current_line.is_empty() {
|
||||
lines.push(current_line.clone());
|
||||
current_line.clear();
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
// Insert space if there's a gap between items on the same line
|
||||
let gap = item.x - last_x_end;
|
||||
if gap > item.font_size * 0.15 {
|
||||
current_line.push(' ');
|
||||
}
|
||||
}
|
||||
|
||||
current_line.push_str(&item.text);
|
||||
last_y = item.y;
|
||||
last_x_end = item.x + item.width;
|
||||
let mut thresholds = HashMap::new();
|
||||
if adaptive_threshold > 0.10 {
|
||||
thresholds.insert(page, adaptive_threshold);
|
||||
}
|
||||
let lines = extractor::group_into_lines_with_thresholds(matched, &thresholds, &HashSet::new());
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
fn infer_region_coord_space(items: &[TextItem]) -> RegionCoordSpace {
|
||||
// Rotated-page normalization currently maps y = -old_x, so most text items
|
||||
// land at negative Y. Use this to keep `collect_text_in_region` behavior
|
||||
// compatible for direct callers that do not have extractor metadata.
|
||||
let negative_y = items.iter().filter(|item| item.y < 0.0).count();
|
||||
if !items.is_empty() && negative_y * 2 >= items.len() {
|
||||
RegionCoordSpace::Rotated90Ccw
|
||||
} else {
|
||||
RegionCoordSpace::Standard
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
fn region_bounds(
|
||||
rx1: f32,
|
||||
ry1: f32,
|
||||
rx2: f32,
|
||||
ry2: f32,
|
||||
page_height: f32,
|
||||
coord_space: RegionCoordSpace,
|
||||
) -> RegionBounds {
|
||||
let tx_min = rx1.min(rx2);
|
||||
let tx_max = rx1.max(rx2);
|
||||
let ty_min = ry1.min(ry2);
|
||||
let ty_max = ry1.max(ry2);
|
||||
let by_min = page_height - ty_max;
|
||||
let by_max = page_height - ty_min;
|
||||
match coord_space {
|
||||
RegionCoordSpace::Standard => RegionBounds {
|
||||
x_min: tx_min,
|
||||
y_min: by_min,
|
||||
x_max: tx_max,
|
||||
y_max: by_max,
|
||||
},
|
||||
RegionCoordSpace::Rotated90Ccw => RegionBounds {
|
||||
x_min: by_min,
|
||||
x_max: by_max,
|
||||
y_min: -tx_max,
|
||||
y_max: -tx_min,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
|
||||
const REGION_MARGIN: f32 = 1.5;
|
||||
let item_x_min = item.x;
|
||||
let item_x_max = item.x + text_utils::effective_width(item);
|
||||
let item_y_min = item.y;
|
||||
let item_y_max = item.y + item.height;
|
||||
|
||||
let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
|
||||
- item_x_min.max(bounds.x_min - REGION_MARGIN))
|
||||
.max(0.0);
|
||||
let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
|
||||
- item_y_min.max(bounds.y_min - REGION_MARGIN))
|
||||
.max(0.0);
|
||||
x_overlap > 0.0 && y_overlap > 0.0
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -8,6 +8,24 @@ use log::debug;
|
||||
/// Font statistics for a document
|
||||
pub(crate) struct FontStats {
|
||||
pub(crate) most_common_size: f32,
|
||||
/// Font size frequency distribution (size_key → line count).
|
||||
/// Used for rarity-based heading detection.
|
||||
pub(crate) size_counts: HashMap<i32, usize>,
|
||||
/// Total number of lines counted.
|
||||
pub(crate) total_lines: usize,
|
||||
}
|
||||
|
||||
/// Compute how rare a font size is in the document (0.0 = most common, 1.0 = unique).
|
||||
/// Mirrors opendataloader's font rarity boosting approach: heading fonts appear on
|
||||
/// far fewer lines than body text, so their percentile rank is high.
|
||||
pub(crate) fn font_size_rarity(font_size: f32, stats: &FontStats) -> f32 {
|
||||
if stats.total_lines == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let key = (font_size * 10.0) as i32;
|
||||
let count = stats.size_counts.get(&key).copied().unwrap_or(0);
|
||||
// Rarity = 1 - (frequency ratio). A size used on 1/100 lines has rarity ~0.99.
|
||||
1.0 - (count as f32 / stats.total_lines as f32)
|
||||
}
|
||||
|
||||
/// Calculate font stats directly from items (before grouping into lines)
|
||||
@@ -21,6 +39,8 @@ pub(crate) fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
|
||||
}
|
||||
}
|
||||
|
||||
let total_lines = size_counts.values().sum();
|
||||
|
||||
// Break ties by preferring the smaller font size for deterministic output
|
||||
let most_common_size = size_counts
|
||||
.iter()
|
||||
@@ -30,7 +50,11 @@ pub(crate) fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
|
||||
.map(|(size, _)| *size as f32 / 10.0)
|
||||
.unwrap_or(12.0);
|
||||
|
||||
FontStats { most_common_size }
|
||||
FontStats {
|
||||
most_common_size,
|
||||
size_counts,
|
||||
total_lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate font stats from grouped lines
|
||||
@@ -48,6 +72,8 @@ pub(crate) fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
|
||||
}
|
||||
}
|
||||
|
||||
let total_lines = size_counts.values().sum();
|
||||
|
||||
// Break ties by preferring the smaller font size for deterministic output
|
||||
let most_common_size = size_counts
|
||||
.iter()
|
||||
@@ -57,7 +83,23 @@ pub(crate) fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
|
||||
.map(|(size, _)| *size as f32 / 10.0)
|
||||
.unwrap_or(12.0);
|
||||
|
||||
FontStats { most_common_size }
|
||||
FontStats {
|
||||
most_common_size,
|
||||
size_counts,
|
||||
total_lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the heading level for a bold-only line that didn't meet the font-size
|
||||
/// threshold. These are common in academic papers where section headings are bold
|
||||
/// at the same size as body text.
|
||||
///
|
||||
/// Returns a level below the lowest font-size tier (or H2 when no tiers exist).
|
||||
pub(crate) fn bold_heading_level(heading_tiers: &[f32]) -> usize {
|
||||
let level = heading_tiers.len() + 1;
|
||||
// Clamp to 1..=6 — if no font-size tiers, bold headings become H2
|
||||
// (H1 is reserved for titles which are typically larger)
|
||||
level.clamp(2, 6)
|
||||
}
|
||||
|
||||
/// Detect TOC-style lines that contain dot leaders (e.g., "Section Name .... 42").
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
pub(crate) fn is_caption_line(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
|
||||
// Common caption prefixes in multiple languages
|
||||
let caption_prefixes = [
|
||||
"Figure ",
|
||||
// Caption prefixes that always match (always followed by identifiers)
|
||||
let always_prefixes = [
|
||||
"Figura ",
|
||||
"Fig. ",
|
||||
"Fig ",
|
||||
"Table ",
|
||||
"Tabela ",
|
||||
"Source:",
|
||||
"Fonte:",
|
||||
@@ -27,17 +25,39 @@ pub(crate) fn is_caption_line(text: &str) -> bool {
|
||||
"Photo ",
|
||||
"Foto ",
|
||||
];
|
||||
|
||||
// Check if line starts with a caption prefix
|
||||
for prefix in &caption_prefixes {
|
||||
for prefix in &always_prefixes {
|
||||
if trimmed.starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check case-insensitive patterns
|
||||
// "Figure" and "Table" need a digit/reference after them to distinguish
|
||||
// captions ("Table 1", "Figure 3.2") from headings ("Table of Contents")
|
||||
for prefix in ["Figure ", "Table "] {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
||||
if rest
|
||||
.trim_start()
|
||||
.starts_with(|c: char| c.is_ascii_digit() || c == '(' || c == '#')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check case-insensitive patterns — require digit or punctuation after
|
||||
// prefix to avoid matching "Table of Contents" or "Figure drawing" etc.
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("figure ") || lower.starts_with("table ") || lower.starts_with("source:") {
|
||||
for pfx in ["figure ", "table "] {
|
||||
if let Some(rest) = lower.strip_prefix(pfx) {
|
||||
if rest
|
||||
.trim_start()
|
||||
.starts_with(|c: char| c.is_ascii_digit() || c == '(' || c == '#')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if lower.starts_with("source:") {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+48
-4
@@ -6,8 +6,8 @@ use crate::structure_tree::StructRole;
|
||||
use crate::types::TextLine;
|
||||
|
||||
use super::analysis::{
|
||||
calculate_font_stats, compute_heading_tiers, compute_paragraph_threshold, detect_header_level,
|
||||
has_dot_leaders,
|
||||
bold_heading_level, calculate_font_stats, compute_heading_tiers, compute_paragraph_threshold,
|
||||
detect_header_level, font_size_rarity, has_dot_leaders,
|
||||
};
|
||||
use super::classify::{format_list_item, is_caption_line, is_list_item, is_monospace_font};
|
||||
use super::postprocess::clean_markdown;
|
||||
@@ -443,7 +443,33 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
&& plain_trimmed.split_whitespace().count() <= 15
|
||||
{
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers)
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
|
||||
// Rarity-based heading detection (inspired by opendataloader).
|
||||
// Score = font_rarity * 0.5 + bold * 0.3 + standalone * 0.2
|
||||
// Lines scoring above threshold are promoted to headings.
|
||||
// Only consider lines at or above body font size.
|
||||
if line_font_size < base_size * 0.95 {
|
||||
return None;
|
||||
}
|
||||
let word_count = plain_trimmed.split_whitespace().count();
|
||||
if !(1..=15).contains(&word_count) {
|
||||
return None;
|
||||
}
|
||||
let rarity = font_size_rarity(line_font_size, &font_stats);
|
||||
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
|
||||
let standalone = !in_paragraph;
|
||||
|
||||
let score = rarity * 0.5
|
||||
+ if all_bold { 0.3 } else { 0.0 }
|
||||
+ if standalone { 0.2 } else { 0.0 };
|
||||
|
||||
// Require standalone + at least one other signal
|
||||
if score >= 0.5 && standalone && word_count >= 3 {
|
||||
Some(bold_heading_level(&heading_tiers))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -699,7 +725,25 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
{
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
if let Some(header_level) =
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers)
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
|
||||
if line_font_size < base_size * 0.95 {
|
||||
return None;
|
||||
}
|
||||
let word_count = plain_trimmed.split_whitespace().count();
|
||||
if !(1..=15).contains(&word_count) {
|
||||
return None;
|
||||
}
|
||||
let rarity = font_size_rarity(line_font_size, &font_stats);
|
||||
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
|
||||
let standalone = !in_paragraph;
|
||||
let score = rarity * 0.5
|
||||
+ if all_bold { 0.3 } else { 0.0 }
|
||||
+ if standalone { 0.2 } else { 0.0 };
|
||||
if score >= 0.5 && standalone && word_count >= 3 {
|
||||
return Some(bold_heading_level(&heading_tiers));
|
||||
}
|
||||
None
|
||||
})
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
|
||||
+70
-5
@@ -601,6 +601,15 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
let group = page_groups.get(&page).unwrap();
|
||||
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
||||
|
||||
// Detect columns early — on multi-column pages, the merged-band retry
|
||||
// should skip body-font heuristic table detection (which mistakes column
|
||||
// text for tables). Individual band heuristic detection is left enabled
|
||||
// because bands are scoped to single columns.
|
||||
let page_has_columns = {
|
||||
let cols = crate::extractor::detect_columns(&page_items, page, false);
|
||||
cols.len() >= 2
|
||||
};
|
||||
|
||||
// Check for side-by-side layout (e.g. two tables placed left and right)
|
||||
let mut bands = split_side_by_side(&page_items);
|
||||
// Fallback: use rect hint regions to detect side-by-side layout
|
||||
@@ -873,10 +882,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
|
||||
}
|
||||
|
||||
// 4. Column-based table detection: last resort for borderless tabular
|
||||
// layouts (e.g. exam/reference grids) when ALL structural methods
|
||||
// found nothing. Only runs when no rects/lines exist (truly borderless)
|
||||
// and no other detection method found tables in this band.
|
||||
// 4. Column-based table detection for borderless tabular layouts.
|
||||
let band_has_tables = band_items.iter().enumerate().any(|(idx, _)| {
|
||||
band_index_map
|
||||
.get(idx)
|
||||
@@ -903,6 +909,65 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Thin-rect border synthesis: last resort for PDFs that draw table
|
||||
// borders as thin filled rectangles (common in spreadsheet exports).
|
||||
// Only runs when ALL other methods found nothing on this page.
|
||||
if !page_tables.contains_key(&page) {
|
||||
let page_rects: Vec<&crate::types::PdfRect> =
|
||||
rects.iter().filter(|r| r.page == page).collect();
|
||||
let mut synth_lines: Vec<crate::types::PdfLine> = Vec::new();
|
||||
for r in &page_rects {
|
||||
let (mut w, mut h) = (r.width, r.height);
|
||||
let (mut x, mut y) = (r.x, r.y);
|
||||
if w < 0.0 {
|
||||
x += w;
|
||||
w = -w;
|
||||
}
|
||||
if h < 0.0 {
|
||||
y += h;
|
||||
h = -h;
|
||||
}
|
||||
if h < 2.0 && w >= 10.0 {
|
||||
let mid_y = y + h / 2.0;
|
||||
synth_lines.push(crate::types::PdfLine {
|
||||
x1: x,
|
||||
y1: mid_y,
|
||||
x2: x + w,
|
||||
y2: mid_y,
|
||||
page,
|
||||
});
|
||||
} else if w < 2.0 && h >= 10.0 {
|
||||
let mid_x = x + w / 2.0;
|
||||
synth_lines.push(crate::types::PdfLine {
|
||||
x1: mid_x,
|
||||
y1: y,
|
||||
x2: mid_x,
|
||||
y2: y + h,
|
||||
page,
|
||||
});
|
||||
}
|
||||
}
|
||||
if synth_lines.len() >= 10 {
|
||||
let page_text: Vec<TextItem> = text_items
|
||||
.iter()
|
||||
.filter(|i| i.page == page)
|
||||
.cloned()
|
||||
.collect();
|
||||
let line_tables = detect_tables_from_lines(&page_text, &synth_lines, page);
|
||||
for table in &line_tables {
|
||||
for &idx in &table.item_indices {
|
||||
table_items.insert(idx);
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(table);
|
||||
page_tables
|
||||
.entry(page)
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merged-band retry: if we split into bands but found no tables in
|
||||
// any band, retry heuristic detection with all items as a single band.
|
||||
// This catches borderless tables whose text-column alignment was
|
||||
@@ -915,7 +980,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
band_items.len(),
|
||||
was_split
|
||||
);
|
||||
let heuristic_tables = detect_tables(band_items, base_size, false);
|
||||
let heuristic_tables = detect_tables(band_items, base_size, page_has_columns);
|
||||
for table in &heuristic_tables {
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||||
|
||||
+26
-13
@@ -248,21 +248,34 @@ fn convert_text_items(items: Vec<crate::TextItem>) -> Vec<PyTextItem> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_page_regions(page_regions: Vec<(u32, Vec<Vec<f64>>)>) -> Vec<(u32, Vec<[f32; 4]>)> {
|
||||
fn parse_page_regions(
|
||||
page_regions: Vec<(u32, Vec<Vec<f64>>)>,
|
||||
) -> PyResult<Vec<(u32, Vec<[f32; 4]>)>> {
|
||||
page_regions
|
||||
.into_iter()
|
||||
.map(|(page, regions)| {
|
||||
let bboxes: Vec<[f32; 4]> = regions
|
||||
.iter()
|
||||
.map(|r| {
|
||||
if r.len() != 4 {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
} else {
|
||||
[r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(page, bboxes)
|
||||
let mut bboxes: Vec<[f32; 4]> = Vec::with_capacity(regions.len());
|
||||
for (idx, region) in regions.into_iter().enumerate() {
|
||||
if region.len() != 4 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Invalid region at page {page}, index {idx}: expected [x1, y1, x2, y2], got {} values",
|
||||
region.len()
|
||||
)));
|
||||
}
|
||||
let [x1, y1, x2, y2] = [region[0], region[1], region[2], region[3]];
|
||||
if !(x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()) {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Invalid region at page {page}, index {idx}: coordinates must be finite numbers"
|
||||
)));
|
||||
}
|
||||
if x2 < x1 || y2 < y1 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Invalid region at page {page}, index {idx}: expected x2>=x1 and y2>=y1, got [{x1}, {y1}, {x2}, {y2}]"
|
||||
)));
|
||||
}
|
||||
bboxes.push([x1 as f32, y1 as f32, x2 as f32, y2 as f32]);
|
||||
}
|
||||
Ok((page, bboxes))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -424,7 +437,7 @@ fn extract_text_in_regions_bytes(
|
||||
data: &[u8],
|
||||
page_regions: Vec<(u32, Vec<Vec<f64>>)>,
|
||||
) -> PyResult<Vec<PyPageRegionTexts>> {
|
||||
let regions = parse_page_regions(page_regions);
|
||||
let regions = parse_page_regions(page_regions)?;
|
||||
let results = crate::extract_text_in_regions_mem(data, ®ions).map_err(to_py_err)?;
|
||||
Ok(convert_region_results(results))
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
|
||||
body_font_low,
|
||||
body_font_high,
|
||||
);
|
||||
if body_candidates.len() >= 9 {
|
||||
if body_candidates.len() >= 6 {
|
||||
let regions = find_table_regions_strict(&body_candidates);
|
||||
log::debug!("body-font: {} strict regions found", regions.len());
|
||||
|
||||
@@ -241,7 +241,7 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
|
||||
body_candidates.len()
|
||||
);
|
||||
|
||||
if region_items.len() < 9 {
|
||||
if region_items.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -801,7 +801,25 @@ fn has_table_like_content(cells: &[Vec<String>], mode: TableDetectionMode) -> bo
|
||||
// Bypass content check for wide tables (3+ columns) — text-only tables
|
||||
// (category lists, program descriptions) are legitimate if they passed
|
||||
// all structural validations (alignment, consistency, not key-value).
|
||||
pct_data > min_pct || num_cols >= 3
|
||||
// Also bypass for 2-column body-font tables with short cells (avg ≤40 chars),
|
||||
// which are likely definition/category lists, not paragraph text.
|
||||
if pct_data > min_pct || num_cols >= 3 {
|
||||
return true;
|
||||
}
|
||||
if num_cols == 2 && matches!(mode, TableDetectionMode::BodyFont) {
|
||||
let non_empty: Vec<usize> = cells
|
||||
.iter()
|
||||
.skip(1)
|
||||
.flat_map(|row| row.iter())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.map(|c| c.trim().len())
|
||||
.collect();
|
||||
if !non_empty.is_empty() {
|
||||
let avg_len = non_empty.iter().sum::<usize>() / non_empty.len();
|
||||
return avg_len <= 25;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a cell value looks like table data
|
||||
|
||||
@@ -243,9 +243,11 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
|
||||
.map(|s| (s - mean_spacing).powi(2))
|
||||
.sum::<f32>()
|
||||
/ spacings.len() as f32;
|
||||
let cv = variance.sqrt() / mean_spacing; // coefficient of variation
|
||||
// CV < 0.05 means nearly identical spacing — chart grid
|
||||
if cv < 0.05 {
|
||||
let cv = variance.sqrt() / mean_spacing;
|
||||
// CV < 0.02 means nearly identical spacing — likely chart grid.
|
||||
// Spreadsheet-exported tables often have uniform rows (CV 0.03-0.05),
|
||||
// so we use a tighter threshold to avoid false negatives.
|
||||
if cv < 0.02 {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
+81
-11
@@ -1771,15 +1771,49 @@ impl FontCMaps {
|
||||
/// Iterates every page, collects fonts (including Form XObject fonts),
|
||||
/// and parses any `/ToUnicode` streams via lopdf's decompression.
|
||||
pub fn from_doc(doc: &Document) -> Self {
|
||||
Self::from_doc_pages(doc, None)
|
||||
}
|
||||
|
||||
/// Build FontCMaps for specific pages only. Pass `None` for all pages.
|
||||
pub fn from_doc_pages(doc: &Document, page_filter: Option<&HashSet<u32>>) -> Self {
|
||||
Self::from_doc_pages_inner(doc, page_filter, false)
|
||||
}
|
||||
|
||||
/// Build FontCMaps in fast mode: skip expensive TrueType font fallback
|
||||
/// parsing. Fonts that can't be decoded from their ToUnicode CMap alone
|
||||
/// will be missing, causing text extraction to produce empty/garbage text
|
||||
/// which triggers `needs_ocr` fallback. This is ideal for hybrid OCR
|
||||
/// pipelines where GPU OCR is always available as a fallback.
|
||||
pub fn from_doc_pages_fast(doc: &Document, page_filter: Option<&HashSet<u32>>) -> Self {
|
||||
Self::from_doc_pages_inner(doc, page_filter, true)
|
||||
}
|
||||
|
||||
fn from_doc_pages_inner(
|
||||
doc: &Document,
|
||||
page_filter: Option<&HashSet<u32>>,
|
||||
skip_truetype_fallback: bool,
|
||||
) -> Self {
|
||||
let mut by_obj_num: HashMap<u32, CMapEntry> = HashMap::new();
|
||||
|
||||
for (_page_num, &page_id) in doc.get_pages().iter() {
|
||||
for (page_num, &page_id) in doc.get_pages().iter() {
|
||||
if let Some(filter) = page_filter {
|
||||
if !filter.contains(page_num) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Page-level fonts (includes inherited parent resources)
|
||||
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
||||
Self::collect_cmaps_from_fonts(&fonts, doc, &mut by_obj_num);
|
||||
Self::collect_cmaps_from_fonts_inner(
|
||||
&fonts,
|
||||
doc,
|
||||
&mut by_obj_num,
|
||||
skip_truetype_fallback,
|
||||
);
|
||||
|
||||
// Fonts inside Form XObjects referenced by this page
|
||||
Self::collect_cmaps_from_xobjects(doc, page_id, &mut by_obj_num);
|
||||
if !skip_truetype_fallback {
|
||||
// Fonts inside Form XObjects referenced by this page
|
||||
Self::collect_cmaps_from_xobjects(doc, page_id, &mut by_obj_num);
|
||||
}
|
||||
}
|
||||
|
||||
FontCMaps { by_obj_num }
|
||||
@@ -1792,6 +1826,15 @@ impl FontCMaps {
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
doc: &Document,
|
||||
by_obj_num: &mut HashMap<u32, CMapEntry>,
|
||||
) {
|
||||
Self::collect_cmaps_from_fonts_inner(fonts, doc, by_obj_num, false);
|
||||
}
|
||||
|
||||
fn collect_cmaps_from_fonts_inner(
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
doc: &Document,
|
||||
by_obj_num: &mut HashMap<u32, CMapEntry>,
|
||||
skip_truetype_fallback: bool,
|
||||
) {
|
||||
// First pass: collect ToUnicode CMaps
|
||||
for font_dict in fonts.values() {
|
||||
@@ -1825,13 +1868,32 @@ impl FontCMaps {
|
||||
);
|
||||
let (mut primary, mut remapped) =
|
||||
try_remap_subset_cmap(cmap, font_dict, doc, obj_num);
|
||||
let mut fallback = build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_type0(font_dict, doc))
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||||
|
||||
// If the ToUnicode map is extremely sparse, prefer the fallback
|
||||
// (often a better mapping for Symbol/Wingdings/Arabic CID fonts).
|
||||
// Only build expensive fallbacks when the primary CMap is sparse.
|
||||
// build_fallback_cmap_for_type0 can take seconds on large embedded
|
||||
// TrueType fonts (decompressing + parsing 100K+ byte font files).
|
||||
// Skip entirely when the primary CMap is sufficient.
|
||||
let primary_entries = primary.char_map.len() + primary.ranges.len();
|
||||
let mut fallback = if primary_entries < 10 && !skip_truetype_fallback {
|
||||
// Try cheap fallback first; only attempt expensive TrueType
|
||||
// parsing if cheap fallbacks don't yield results.
|
||||
let cheap = build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||||
if cheap.is_some() {
|
||||
cheap
|
||||
} else {
|
||||
build_fallback_cmap_for_type0(font_dict, doc)
|
||||
}
|
||||
} else if primary_entries < 10 {
|
||||
// Fast mode: only try cheap fallbacks, skip TrueType parsing.
|
||||
// Regions using this font will get needs_ocr=true.
|
||||
build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc))
|
||||
} else {
|
||||
// Primary is rich enough; only try the cheap encoding fallback
|
||||
build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
};
|
||||
|
||||
if primary_entries < 10 {
|
||||
if let Some(fb) = fallback.take() {
|
||||
debug!(
|
||||
@@ -1852,8 +1914,12 @@ impl FontCMaps {
|
||||
);
|
||||
} else {
|
||||
// ToUnicode present but parse failed; try fallbacks to avoid empty decoding.
|
||||
let fallback = build_fallback_cmap_for_type0(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||||
let fallback = if skip_truetype_fallback {
|
||||
build_fallback_cmap_for_simple(font_dict, doc)
|
||||
} else {
|
||||
build_fallback_cmap_for_type0(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc))
|
||||
};
|
||||
if let Some(fb) = fallback {
|
||||
debug!(
|
||||
"ToUnicode CMap obj={} parse failed; using fallback (entries={})",
|
||||
@@ -1874,6 +1940,10 @@ impl FontCMaps {
|
||||
|
||||
// Second pass: Identity-H/V fonts without ToUnicode
|
||||
// Try: (1) embedded TrueType/OpenType cmap, (2) predefined CID→Unicode mapping
|
||||
// Skip entirely in fast mode — these fonts require expensive TrueType parsing.
|
||||
if skip_truetype_fallback {
|
||||
return;
|
||||
}
|
||||
for font_dict in fonts.values() {
|
||||
if font_dict.get(b"ToUnicode").is_ok() {
|
||||
continue;
|
||||
|
||||
+239
-2
@@ -4,9 +4,11 @@ use pdf_inspector::detector::{DetectionConfig, ScanStrategy};
|
||||
use pdf_inspector::extractor::group_into_lines;
|
||||
use pdf_inspector::types::TextLine;
|
||||
use pdf_inspector::{
|
||||
detect_pdf_type, extract_text, extract_text_with_positions, process_pdf_with_options,
|
||||
to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
|
||||
detect_pdf_type, extract_text, extract_text_in_regions_mem, extract_text_with_positions,
|
||||
process_pdf_mem, process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions,
|
||||
PdfType, TextItem,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Helper to create test TextItems
|
||||
fn make_text_item(text: &str, x: f32, y: f32, font_size: f32, page: u32) -> TextItem {
|
||||
@@ -1107,3 +1109,238 @@ fn test_rotated_table_layout_correction() {
|
||||
"District data should be in a markdown table row"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// extract_text_in_regions_mem tests
|
||||
// =========================================================================
|
||||
|
||||
/// Build full-page region args for `page_count` pages.
|
||||
/// Uses a generously large bbox (1200x1200) to capture any page size.
|
||||
fn full_page_regions(page_count: u32) -> Vec<(u32, Vec<[f32; 4]>)> {
|
||||
(0..page_count)
|
||||
.map(|p| (p, vec![[0.0, 0.0, 1200.0, 1200.0]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Normalize text for comparison: lowercase, strip non-alphanumeric, split into words.
|
||||
fn normalize_words(text: &str) -> HashSet<String> {
|
||||
text.split(|c: char| !c.is_alphanumeric())
|
||||
.map(|w| w.to_lowercase())
|
||||
.filter(|w| w.len() > 3)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fraction of normalized words in `a` that also appear in `b`.
|
||||
fn word_overlap_ratio(a: &str, b: &str) -> f64 {
|
||||
let words_a = normalize_words(a);
|
||||
if words_a.is_empty() {
|
||||
return if normalize_words(b).is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
let words_b = normalize_words(b);
|
||||
let overlap = words_a.intersection(&words_b).count();
|
||||
overlap as f64 / words_a.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_basic_text_pdf() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let result = process_pdf_mem(&buf).unwrap();
|
||||
let page_count = result.page_count;
|
||||
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(page_count)).unwrap();
|
||||
assert_eq!(regions.len(), page_count as usize);
|
||||
|
||||
// Each result should have exactly 1 region (we passed one per page)
|
||||
for r in ®ions {
|
||||
assert_eq!(r.regions.len(), 1);
|
||||
}
|
||||
|
||||
// First page should have non-empty text
|
||||
let first = ®ions[0].regions[0];
|
||||
assert!(!first.text.trim().is_empty(), "First page should have text");
|
||||
assert_eq!(regions[0].page, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_identity_h_needs_ocr() {
|
||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||
let regions =
|
||||
extract_text_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(
|
||||
regions[0].regions[0].needs_ocr,
|
||||
"Identity-H font without ToUnicode should trigger needs_ocr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_multiple_regions_per_page() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let regions = extract_text_in_regions_mem(
|
||||
&buf,
|
||||
&[(
|
||||
0,
|
||||
vec![
|
||||
[0.0, 0.0, 300.0, 100.0], // small top-left
|
||||
[0.0, 0.0, 1200.0, 1200.0], // full page
|
||||
],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].regions.len(), 2);
|
||||
|
||||
let small_len = regions[0].regions[0].text.len();
|
||||
let full_len = regions[0].regions[1].text.len();
|
||||
assert!(
|
||||
full_len >= small_len,
|
||||
"Full-page region ({full_len}) should have at least as much text as small region ({small_len})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_nonexistent_page() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let regions =
|
||||
extract_text_in_regions_mem(&buf, &[(9999, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(
|
||||
regions[0].regions[0].needs_ocr,
|
||||
"Nonexistent page should trigger needs_ocr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_empty_region() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let regions = extract_text_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 0.0, 0.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(
|
||||
regions[0].regions[0].needs_ocr,
|
||||
"Zero-area region should trigger needs_ocr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_not_a_pdf() {
|
||||
let result = extract_text_in_regions_mem(b"not a pdf", &[(0, vec![[0.0, 0.0, 100.0, 100.0]])]);
|
||||
assert!(result.is_err(), "Non-PDF input should return an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_rotated_page_not_false_empty() {
|
||||
let buf = std::fs::read("tests/fixtures/tnagriculture_06_12.pdf").unwrap();
|
||||
let regions =
|
||||
extract_text_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].regions.len(), 1);
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.trim().is_empty(),
|
||||
"Rotated page full-region extraction should not be empty"
|
||||
);
|
||||
assert!(
|
||||
!region.needs_ocr,
|
||||
"Rotated page with native text should not be flagged for OCR fallback"
|
||||
);
|
||||
assert!(
|
||||
region
|
||||
.text
|
||||
.contains("DISTRICT WISE PRODUCTION OF SPICES AND CONDIMENTS"),
|
||||
"Expected known title from rotated fixture in extracted region text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_text_in_region_keeps_partial_overlap_items() {
|
||||
let item = make_text_item("EdgeWord", 100.0, 700.0, 12.0, 1);
|
||||
// Region intersects only the left edge of the item. Center x=124 falls
|
||||
// outside x=[95,120], so center-only containment would drop it.
|
||||
let text = pdf_inspector::collect_text_in_region(&[item], 95.0, 80.0, 120.0, 110.0, 800.0);
|
||||
assert!(
|
||||
text.contains("EdgeWord"),
|
||||
"Partially overlapping items should be retained in region extraction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_text_in_region_uses_rtl_sorting() {
|
||||
let items = vec![
|
||||
make_text_item("بكم", 240.0, 700.0, 12.0, 1),
|
||||
make_text_item("مرحبا", 300.0, 700.0, 12.0, 1),
|
||||
];
|
||||
let text = pdf_inspector::collect_text_in_region(&items, 0.0, 0.0, 600.0, 800.0, 800.0);
|
||||
assert_eq!(
|
||||
text, "مرحبا بكم",
|
||||
"Region path should reuse RTL-aware line sorting"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fast vs normal extraction comparison
|
||||
// =========================================================================
|
||||
|
||||
/// For each text-based fixture PDF, compare `extract_text_in_regions_mem` (fast path)
|
||||
/// against `process_pdf_mem` (normal path). If the fast path claims needs_ocr=false
|
||||
/// for a page, verify the extracted text has meaningful overlap with the normal
|
||||
/// markdown output — catching silent quality regressions.
|
||||
#[test]
|
||||
fn test_extract_regions_fast_vs_normal_comparison() {
|
||||
let fixtures = [
|
||||
"tests/fixtures/nexo-price-en.pdf",
|
||||
"tests/fixtures/td9264.pdf",
|
||||
"tests/fixtures/p1244-1996.pdf",
|
||||
"tests/fixtures/real-estate-pricing.pdf",
|
||||
"tests/fixtures/2013-app2.pdf",
|
||||
"tests/fixtures/firecrawl_docs_tagged.pdf",
|
||||
"tests/fixtures/thermo-freon12.pdf",
|
||||
];
|
||||
|
||||
for fixture in &fixtures {
|
||||
let buf = std::fs::read(fixture).unwrap();
|
||||
let normal = process_pdf_mem(&buf).unwrap();
|
||||
let normal_md = normal.markdown.as_deref().unwrap_or("");
|
||||
let page_count = normal.page_count;
|
||||
let ocr_pages: HashSet<u32> = normal.pages_needing_ocr.iter().copied().collect();
|
||||
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(page_count)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
regions.len(),
|
||||
page_count as usize,
|
||||
"{fixture}: result count should match page count"
|
||||
);
|
||||
|
||||
for pr in ®ions {
|
||||
let region = &pr.regions[0];
|
||||
if !region.needs_ocr && !region.text.trim().is_empty() {
|
||||
// Fast path claims this text is trustworthy.
|
||||
// Check that its words appear in the normal markdown output.
|
||||
let overlap = word_overlap_ratio(®ion.text, normal_md);
|
||||
assert!(
|
||||
overlap >= 0.3,
|
||||
"{fixture} page {}: fast path says needs_ocr=false but only {:.0}% word \
|
||||
overlap with normal extraction (threshold 30%). \
|
||||
Fast text sample: {:?}",
|
||||
pr.page,
|
||||
overlap * 100.0,
|
||||
®ion.text[..region.text.len().min(200)],
|
||||
);
|
||||
}
|
||||
|
||||
// If fast path flags needs_ocr but normal path didn't, that's overly
|
||||
// conservative but not a bug — just worth knowing.
|
||||
if region.needs_ocr && !ocr_pages.contains(&(pr.page + 1)) {
|
||||
eprintln!(
|
||||
"INFO: {fixture} page {}: fast path says needs_ocr=true but normal path extracted fine (conservative, not a bug)",
|
||||
pr.page,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ Department of the Treasury **Internal Revenue Service**
|
||||
|
||||
# and Report to Employer
|
||||
|
||||
**This publication contains:** **Form 4070A, Employee’s Daily Record of** Tips **Form 4070, Employee’s Report of Tips to** Employer
|
||||
### This publication contains:
|
||||
|
||||
**Form 4070A, Employee’s Daily Record of** Tips **Form 4070, Employee’s Report of Tips to** Employer
|
||||
|
||||
For the period
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
|
||||
8 4 Z E L L / L U R I E R E A L E S T A T E C E N T E R
|
||||
|
||||
**Table I: Cap rate correlations**
|
||||
|
||||
**Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
|
||||
**Table I: Cap rate correlations** **Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
|
||||
|
||||
* Based on 25 years of data for the 10-yrT & S&P DivYld; and 14 years for BBB.
|
||||
**Figure 1:** NCREIF cap rates vs. 10-yearTreasury
|
||||
@@ -34,9 +32,7 @@ R E V I E W 8 5
|
||||
|
||||
1982 1986 1990 1994 1998 2002 2006
|
||||
|
||||
**Table II: Correlationsofspreadsbypropertytype**
|
||||
|
||||
**Correlation of Cap Rate Spreads Over Treasury** **Multifamily Industrial CBD Office**
|
||||
**Table II: Correlationsofspreadsbypropertytype** **Correlation of Cap Rate Spreads Over Treasury** **Multifamily Industrial CBD Office**
|
||||
|
||||
||Multifamily|Industrial|CBD Office|
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -26,19 +26,16 @@ S = Entropy (kJ/kg.K)
|
||||
|
||||
**Physical Properties**
|
||||
|
||||
Chemical Formula CCl2F2
|
||||
|Chemical Formula|CCl2F2|
|
||||
|---|---|
|
||||
|Molecular mass|120.91|
|
||||
|Boiling Point At one atmosphere|-29.75°C|
|
||||
|Critical Temperature|111.97°C|
|
||||
|Critical Pressure|4136 kPa|
|
||||
|Critical Density|565.0 kg/m|
|
||||
|Critical Volume|0.0018 m|
|
||||
|
||||
Molecular mass 120.91
|
||||
|
||||
Boiling Point-29.75°C At one atmosphere
|
||||
|
||||
Critical Temperature 111.97°C
|
||||
|
||||
Critical Pressure 4136 kPa
|
||||
|
||||
3 Critical Density 565.0 kg/m
|
||||
|
||||
Critical Volume 0.0018 m /kg
|
||||
/kg
|
||||
|
||||
l
|
||||
|
||||
|
||||
@@ -262,6 +262,13 @@ class TestExtractTextInRegions:
|
||||
assert results[0].page == 0
|
||||
assert results[1].page == 1
|
||||
|
||||
def test_malformed_region_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Invalid region"):
|
||||
pdf_inspector.extract_text_in_regions(
|
||||
fixture_path("thermo-freon12.pdf"),
|
||||
[(0, [[0.0, 0.0, 600.0]])],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
|
||||
Reference in New Issue
Block a user