Compare commits
+1
-1
@@ -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"
|
||||
|
||||
@@ -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.78 | 0.87 | 0.59 | 0.57 | 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
|
||||
|
||||
Generated
+1
-19
@@ -129,12 +129,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -679,7 +673,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.40.0"
|
||||
source = "git+https://github.com/J-F-Liu/lopdf?rev=052674053814a9f4897af94f0b8e46a545c9b329#052674053814a9f4897af94f0b8e46a545c9b329"
|
||||
source = "git+https://github.com/J-F-Liu/lopdf?rev=7a05512d831415b1f2b1ce522391d6beab8a1284#7a05512d831415b1f2b1ce522391d6beab8a1284"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bitflags",
|
||||
@@ -695,7 +689,6 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"nom",
|
||||
"nom_locate",
|
||||
"rand",
|
||||
"rangemap",
|
||||
"rayon",
|
||||
@@ -807,17 +800,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom_locate"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"memchr",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "firecrawl-pdf-inspector",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.6",
|
||||
"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",
|
||||
|
||||
+236
-28
@@ -598,7 +598,15 @@ fn page_has_identity_h_no_tounicode(doc: &Document, page_id: ObjectId) -> bool {
|
||||
if font_dict.get(b"ToUnicode").is_ok() {
|
||||
continue;
|
||||
}
|
||||
// Identity-H/V without ToUnicode — flag it
|
||||
|
||||
// Check if fallback decoding paths can handle this font.
|
||||
// The extraction pipeline tries: TrueType cmap → CIDSystemInfo → passthrough.
|
||||
// If any of these would succeed, the font is decodable — don't flag it.
|
||||
if identity_h_font_has_fallback(font_dict, doc) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identity-H/V without ToUnicode and no fallback — flag it
|
||||
log::debug!(
|
||||
"page has Identity-H/V font without ToUnicode: {:?}",
|
||||
font_dict
|
||||
@@ -612,6 +620,102 @@ fn page_has_identity_h_no_tounicode(doc: &Document, page_id: ObjectId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check whether an Identity-H font without ToUnicode can still be decoded
|
||||
/// via one of the extraction pipeline's fallback paths.
|
||||
fn identity_h_font_has_fallback(font_dict: &lopdf::Dictionary, doc: &Document) -> bool {
|
||||
let desc_fonts_obj = match font_dict.get(b"DescendantFonts").ok() {
|
||||
Some(obj) => obj,
|
||||
None => return false,
|
||||
};
|
||||
let desc_fonts = match desc_fonts_obj {
|
||||
Object::Array(arr) => arr,
|
||||
Object::Reference(r) => match doc.get_object(*r) {
|
||||
Ok(Object::Array(arr)) => arr,
|
||||
_ => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
if desc_fonts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let cid_font_dict = match &desc_fonts[0] {
|
||||
Object::Reference(r) => match doc.get_dictionary(*r) {
|
||||
Ok(d) => d,
|
||||
_ => return false,
|
||||
},
|
||||
Object::Dictionary(d) => d,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Fallback 1: W array CIDs look like Unicode codepoints → passthrough works.
|
||||
// Many PDF generators (Chromium, wkhtmltopdf) use Identity-H where CID = Unicode.
|
||||
if crate::tounicode::cid_values_look_like_unicode(cid_font_dict) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback 2: Embedded TrueType/OpenType font has a usable cmap table.
|
||||
if let Some(font_descriptor) = cid_font_dict
|
||||
.get(b"FontDescriptor")
|
||||
.ok()
|
||||
.and_then(|o| match o {
|
||||
Object::Reference(r) => doc.get_dictionary(*r).ok(),
|
||||
Object::Dictionary(d) => Some(d),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
let font_file_ref = font_descriptor
|
||||
.get(b"FontFile2")
|
||||
.ok()
|
||||
.and_then(|o| o.as_reference().ok())
|
||||
.or_else(|| {
|
||||
font_descriptor
|
||||
.get(b"FontFile3")
|
||||
.ok()
|
||||
.and_then(|o| o.as_reference().ok())
|
||||
});
|
||||
if let Some(ff_ref) = font_file_ref {
|
||||
if embedded_font_has_cmap(doc, ff_ref) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Quick check whether an embedded TrueType/OpenType font has a cmap table
|
||||
/// that can map GIDs to Unicode codepoints.
|
||||
fn embedded_font_has_cmap(doc: &Document, font_ref: lopdf::ObjectId) -> bool {
|
||||
let stream = match doc.get_object(font_ref).and_then(Object::as_stream) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let data = match stream.decompressed_content() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let face = match ttf_parser::Face::parse(&data, 0) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
// Check that the font has a cmap table with at least some Unicode mappings
|
||||
if let Some(cmap) = face.tables().cmap {
|
||||
for subtable in cmap.subtables {
|
||||
if subtable.is_unicode()
|
||||
|| (subtable.platform_id == ttf_parser::PlatformId::Windows
|
||||
&& subtable.encoding_id == 0)
|
||||
{
|
||||
let mut count = 0u32;
|
||||
subtable.codepoints(|_| count += 1);
|
||||
if count > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true if every font on the page is Type3 (no normal text fonts).
|
||||
/// Type3 fonts render glyphs as custom drawings/bitmaps. Without a ToUnicode
|
||||
/// CMap, character codes can't be mapped to Unicode — the page needs OCR.
|
||||
@@ -730,7 +834,7 @@ fn scan_content_for_text_operators(
|
||||
unique_chars: &mut HashSet<u8>,
|
||||
) -> (u32, u32, u32, u32) {
|
||||
let mut text_ops = 0u32;
|
||||
let mut image_count = 0u32;
|
||||
let image_count = 0u32;
|
||||
let mut path_ops = 0u32;
|
||||
let mut font_changes = 0u32;
|
||||
|
||||
@@ -771,14 +875,10 @@ fn scan_content_for_text_operators(
|
||||
}
|
||||
}
|
||||
|
||||
// Look for 'Do' operator (XObject/image placement)
|
||||
if b == b'D'
|
||||
&& i + 1 < content.len()
|
||||
&& content[i + 1] == b'o'
|
||||
&& (i + 2 >= content.len() || content[i + 2].is_ascii_whitespace())
|
||||
{
|
||||
image_count += 1;
|
||||
}
|
||||
// Note: We do NOT count 'Do' operators here because Do invokes any
|
||||
// XObject — including Form XObjects that contain text. Actual image
|
||||
// detection is handled by scan_xobjects_in_resources (checks Subtype)
|
||||
// and analyze_page_images (measures pixel area).
|
||||
|
||||
// Count path construction/painting operators.
|
||||
// Single-byte: m (moveto), l (lineto), c (curveto), h (closepath),
|
||||
@@ -1185,23 +1285,24 @@ mod tests {
|
||||
// H, e, l, o = 4 unique
|
||||
assert!(uchars.len() >= 4);
|
||||
|
||||
// Content with Do (image)
|
||||
// Content with Do (XObject invocation — not counted as image here;
|
||||
// actual image detection is handled by scan_xobjects_in_resources)
|
||||
uchars.clear();
|
||||
let content3 = b"q 100 0 0 100 50 700 cm /Img1 Do Q";
|
||||
let (ops3, imgs3, _, _) = scan_content_for_text_operators(content3, &mut uchars);
|
||||
assert_eq!(ops3, 0);
|
||||
assert_eq!(imgs3, 1);
|
||||
assert_eq!(imgs3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_dominated_detection() {
|
||||
// Simulate a page with many Do operators and minimal text
|
||||
// Do operators are no longer counted as images by scan_content_for_text_operators.
|
||||
// Image-dominated detection now relies on scan_xobjects_in_resources which
|
||||
// checks XObject Subtype. Here we verify that Do operators don't inflate image_count.
|
||||
let mut content = Vec::new();
|
||||
// Add 50 Do operators (image-heavy)
|
||||
for i in 0..50 {
|
||||
content.extend_from_slice(format!("/Im{i} Do\n").as_bytes());
|
||||
}
|
||||
// Add a few text operators with only a bullet char
|
||||
content.extend_from_slice(b"BT (x) Tj ET\n");
|
||||
content.extend_from_slice(b"BT (x) Tj ET\n");
|
||||
content.extend_from_slice(b"BT (x) Tj ET\n");
|
||||
@@ -1209,15 +1310,8 @@ mod tests {
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, imgs, _, _) = scan_content_for_text_operators(&content, &mut uchars);
|
||||
assert_eq!(ops, 3);
|
||||
assert_eq!(imgs, 50);
|
||||
// Only 'x' unique char
|
||||
assert_eq!(imgs, 0); // Do operators are not counted here
|
||||
assert_eq!(uchars.len(), 1);
|
||||
|
||||
// This should be image-dominated: 50 > 10 && 50 > 3*3=9
|
||||
let is_image_dominated = imgs > 10 && imgs > ops * 3;
|
||||
assert!(is_image_dominated);
|
||||
// And fails unique char threshold
|
||||
assert!(uchars.len() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1227,12 +1321,9 @@ mod tests {
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, imgs, _, _) = scan_content_for_text_operators(content, &mut uchars);
|
||||
assert_eq!(ops, 1);
|
||||
assert_eq!(imgs, 2);
|
||||
// Many unique chars from the sentence
|
||||
assert_eq!(imgs, 0); // Do operators not counted here
|
||||
// Many unique chars from the sentence
|
||||
assert!(uchars.len() >= 5);
|
||||
// Not image-dominated: 2 > 10 fails
|
||||
let is_image_dominated = imgs > 10 && imgs > ops * 3;
|
||||
assert!(!is_image_dominated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1396,6 +1487,123 @@ mod tests {
|
||||
assert!(!page_has_identity_h_no_tounicode(&doc, page_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_h_with_unicode_cids_not_flagged() {
|
||||
// Type0 Identity-H font without ToUnicode but with W array CIDs
|
||||
// that look like Unicode codepoints (e.g. from Chromium/wkhtmltopdf).
|
||||
// The CID-as-Unicode passthrough can decode these — don't flag.
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let pages_id = doc.new_object_id();
|
||||
let page_id = doc.new_object_id();
|
||||
// CIDFont with W array containing Unicode-range CIDs (>= 0x41)
|
||||
let cid_font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
|
||||
"W" => Object::Array(vec![
|
||||
Object::Integer(0x41), // CID 65 = 'A'
|
||||
Object::Array(vec![
|
||||
Object::Integer(600), Object::Integer(600), Object::Integer(600),
|
||||
]),
|
||||
Object::Integer(0x61), // CID 97 = 'a'
|
||||
Object::Array(vec![
|
||||
Object::Integer(500), Object::Integer(500), Object::Integer(500),
|
||||
]),
|
||||
]),
|
||||
});
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"Type0".to_vec()),
|
||||
"BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
|
||||
"Encoding" => Object::Name(b"Identity-H".to_vec()),
|
||||
"DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
|
||||
});
|
||||
let resources = dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
};
|
||||
doc.objects.insert(
|
||||
page_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => Object::Reference(pages_id),
|
||||
"Resources" => resources,
|
||||
}),
|
||||
);
|
||||
doc.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
"Count" => Object::Integer(1),
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
!page_has_identity_h_no_tounicode(&doc, page_id),
|
||||
"Should NOT flag: W array CIDs look like Unicode, passthrough works"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_h_with_low_gid_cids_still_flagged() {
|
||||
// Type0 Identity-H font without ToUnicode and W array CIDs
|
||||
// that are low GID values (subset font, no cmap). These can't
|
||||
// be decoded — should still be flagged.
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let pages_id = doc.new_object_id();
|
||||
let page_id = doc.new_object_id();
|
||||
// CIDFont with W array containing low GID values (< 0x41)
|
||||
let cid_font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
|
||||
"W" => Object::Array(vec![
|
||||
Object::Integer(3), // Low GID
|
||||
Object::Array(vec![
|
||||
Object::Integer(600), Object::Integer(600), Object::Integer(600),
|
||||
Object::Integer(600), Object::Integer(600),
|
||||
]),
|
||||
Object::Integer(10), // Still low
|
||||
Object::Array(vec![
|
||||
Object::Integer(500), Object::Integer(500), Object::Integer(500),
|
||||
]),
|
||||
]),
|
||||
});
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"Type0".to_vec()),
|
||||
"BaseFont" => Object::Name(b"GPBCHP+TimesNewRoman".to_vec()),
|
||||
"Encoding" => Object::Name(b"Identity-H".to_vec()),
|
||||
"DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
|
||||
});
|
||||
let resources = dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
};
|
||||
doc.objects.insert(
|
||||
page_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => Object::Reference(pages_id),
|
||||
"Resources" => resources,
|
||||
}),
|
||||
);
|
||||
doc.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
"Count" => Object::Integer(1),
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
page_has_identity_h_no_tounicode(&doc, page_id),
|
||||
"Should flag: low GID CIDs, no cmap, no passthrough"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_counts_tf_operators() {
|
||||
let mut uchars = HashSet::new();
|
||||
|
||||
@@ -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());
|
||||
|
||||
+188
-3
@@ -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);
|
||||
@@ -163,10 +164,17 @@ pub(crate) fn detect_columns(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try XY-cut fallback before giving up
|
||||
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
|
||||
return columns;
|
||||
}
|
||||
return vec![ColumnRegion { x_min, x_max }];
|
||||
}
|
||||
|
||||
return validate_and_build_columns(
|
||||
// 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,
|
||||
@@ -175,8 +183,177 @@ pub(crate) fn detect_columns(
|
||||
MIN_ITEMS_PER_COLUMN,
|
||||
MIN_VERTICAL_SPAN_RATIO,
|
||||
page,
|
||||
false, // edge-based assignment for absolute valleys
|
||||
true, // center-based assignment
|
||||
);
|
||||
if result.len() > 1 {
|
||||
return result;
|
||||
}
|
||||
let result = validate_and_build_columns(
|
||||
&valleys,
|
||||
&page_items,
|
||||
x_min,
|
||||
BIN_WIDTH,
|
||||
x_max,
|
||||
MIN_ITEMS_PER_COLUMN,
|
||||
MIN_VERTICAL_SPAN_RATIO,
|
||||
page,
|
||||
false, // edge-based fallback
|
||||
);
|
||||
if result.len() > 1 {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: XY-cut style gap detection. When the histogram finds no
|
||||
// clear valleys (common with asymmetric/sidebar layouts), look for the
|
||||
// largest horizontal gap between item edges. This is a simplified
|
||||
// single-level XY-cut inspired by opendataloader's XY-Cut++ algorithm.
|
||||
if page_items.len() >= 20 && !page_has_table {
|
||||
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
||||
vec![ColumnRegion { x_min, x_max }]
|
||||
}
|
||||
|
||||
/// Simplified single-level XY-cut: find the largest horizontal gap between
|
||||
/// item right-edges and left-edges. If the gap is wide enough and both sides
|
||||
/// have sufficient items with vertical overlap, split into two columns.
|
||||
///
|
||||
/// Inspired by opendataloader's XY-Cut++ algorithm but without full recursion.
|
||||
/// Handles asymmetric layouts (sidebars) that the histogram misses because
|
||||
/// the narrow column has too few items to register in the occupancy profile.
|
||||
fn try_xy_cut_split(
|
||||
page_items: &[&TextItem],
|
||||
page_x_min: f32,
|
||||
page_x_max: f32,
|
||||
page: u32,
|
||||
) -> Option<Vec<ColumnRegion>> {
|
||||
const MIN_GAP: f32 = 15.0; // minimum gap to consider a split
|
||||
const MIN_ITEMS_MAJOR: usize = 10; // major column must have ≥10 items
|
||||
const MIN_ITEMS_MINOR: usize = 3; // minor column (sidebar) must have ≥3
|
||||
|
||||
let page_width = page_x_max - page_x_min;
|
||||
if page_width < 200.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Collect all item edges: (right_edge, left_edge) pairs sorted by right_edge
|
||||
// The gap between one item's right edge and the next item's left edge
|
||||
// reveals column gutters.
|
||||
let mut edges: Vec<(f32, f32)> = page_items
|
||||
.iter()
|
||||
.map(|i| (i.x, i.x + effective_width(i)))
|
||||
.collect();
|
||||
edges.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
// Find the largest gap between consecutive items (by left edge).
|
||||
// Use a sweep: sort left edges, find max gap between sorted right edges
|
||||
// of items to the left and left edges of items to the right.
|
||||
let mut left_edges: Vec<f32> = page_items.iter().map(|i| i.x).collect();
|
||||
left_edges.sort_by(|a, b| a.total_cmp(b));
|
||||
|
||||
// Build prefix max of right edges (for items sorted by left edge)
|
||||
let mut sorted_by_left: Vec<(f32, f32)> = page_items
|
||||
.iter()
|
||||
.map(|i| (i.x, i.x + effective_width(i)))
|
||||
.collect();
|
||||
sorted_by_left.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
let mut best_gap = 0.0f32;
|
||||
let mut best_split = 0.0f32;
|
||||
let mut max_right_so_far = f32::NEG_INFINITY;
|
||||
|
||||
for i in 0..sorted_by_left.len() - 1 {
|
||||
let (_, right) = sorted_by_left[i];
|
||||
max_right_so_far = max_right_so_far.max(right);
|
||||
|
||||
let (next_left, _) = sorted_by_left[i + 1];
|
||||
let gap = next_left - max_right_so_far;
|
||||
if gap > best_gap {
|
||||
best_gap = gap;
|
||||
best_split = (max_right_so_far + next_left) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
if best_gap < MIN_GAP {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Don't split at page margins (within 10% of edges)
|
||||
let margin = page_width * 0.10;
|
||||
if best_split - page_x_min < margin || page_x_max - best_split < margin {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Count items on each side
|
||||
let left_count = page_items
|
||||
.iter()
|
||||
.filter(|i| i.x + effective_width(i) / 2.0 <= best_split)
|
||||
.count();
|
||||
let right_count = page_items
|
||||
.iter()
|
||||
.filter(|i| i.x + effective_width(i) / 2.0 > best_split)
|
||||
.count();
|
||||
|
||||
let (minor, major) = if left_count <= right_count {
|
||||
(left_count, right_count)
|
||||
} else {
|
||||
(right_count, left_count)
|
||||
};
|
||||
|
||||
if major < MIN_ITEMS_MAJOR || minor < MIN_ITEMS_MINOR {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check vertical overlap — both sides should span a meaningful Y range
|
||||
let left_items: Vec<&&TextItem> = page_items
|
||||
.iter()
|
||||
.filter(|i| i.x + effective_width(i) / 2.0 <= best_split)
|
||||
.collect();
|
||||
let right_items: Vec<&&TextItem> = page_items
|
||||
.iter()
|
||||
.filter(|i| i.x + effective_width(i) / 2.0 > best_split)
|
||||
.collect();
|
||||
|
||||
let l_y_min = left_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
|
||||
let l_y_max = left_items
|
||||
.iter()
|
||||
.map(|i| i.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let r_y_min = right_items
|
||||
.iter()
|
||||
.map(|i| i.y)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let r_y_max = right_items
|
||||
.iter()
|
||||
.map(|i| i.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
|
||||
let overlap_min = l_y_min.max(r_y_min);
|
||||
let overlap_max = l_y_max.min(r_y_max);
|
||||
let overlap = (overlap_max - overlap_min).max(0.0);
|
||||
let y_range = (l_y_max.max(r_y_max) - l_y_min.min(r_y_min)).max(1.0);
|
||||
|
||||
if overlap / y_range < 0.20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"page {}: XY-cut split at x={:.1} (gap={:.1}pt, left={}, right={})",
|
||||
page, best_split, best_gap, left_count, right_count
|
||||
);
|
||||
|
||||
Some(vec![
|
||||
ColumnRegion {
|
||||
x_min: page_x_min,
|
||||
x_max: best_split,
|
||||
},
|
||||
ColumnRegion {
|
||||
x_min: best_split,
|
||||
x_max: page_x_max,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/// Check whether each proposed column contains paragraph-like content.
|
||||
@@ -505,7 +682,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);
|
||||
|
||||
+154
-48
@@ -358,6 +358,8 @@ pub fn extract_text_in_regions_mem(
|
||||
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) {
|
||||
@@ -369,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,
|
||||
@@ -377,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);
|
||||
}
|
||||
|
||||
@@ -392,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());
|
||||
|
||||
@@ -399,7 +413,16 @@ 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(),
|
||||
};
|
||||
|
||||
@@ -454,6 +477,20 @@ 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.
|
||||
pub fn collect_text_in_region(
|
||||
@@ -464,65 +501,134 @@ pub 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 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 strict total_cmp ordering to guarantee transitivity (required by
|
||||
// Rust's sort). The line-grouping phase below handles fuzzy Y matching.
|
||||
matched.sort_by(|a, b| {
|
||||
b.y.total_cmp(&a.y) // descending Y = top to bottom
|
||||
.then(a.x.total_cmp(&b.x)) // ascending X = left to right
|
||||
});
|
||||
// Simple extraction: the caller (fire-pdf) already handles reading order
|
||||
// and column splitting via the layout model. We just need to sort items
|
||||
// top-to-bottom, left-to-right and group into lines.
|
||||
let mut sorted = matched;
|
||||
sorted.sort_by(|a, b| b.y.total_cmp(&a.y).then(a.x.total_cmp(&b.x)));
|
||||
|
||||
// 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;
|
||||
let y_tolerance = 3.0;
|
||||
let mut lines: Vec<extractor::TextLine> = Vec::new();
|
||||
|
||||
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();
|
||||
for item in sorted {
|
||||
let should_merge = lines.last().is_some_and(|last_line: &extractor::TextLine| {
|
||||
last_line.page == item.page && (last_line.y - item.y).abs() < y_tolerance
|
||||
});
|
||||
if should_merge {
|
||||
lines.last_mut().unwrap().items.push(item);
|
||||
} else {
|
||||
let y = item.y;
|
||||
let page = item.page;
|
||||
lines.push(extractor::TextLine {
|
||||
items: vec![item],
|
||||
y,
|
||||
page,
|
||||
adaptive_threshold,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
// Sort items within each line by X position
|
||||
for line in &mut lines {
|
||||
text_utils::sort_line_items(&mut line.items);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+210
-8
@@ -1,19 +1,101 @@
|
||||
//! Core line-to-markdown conversion loop with table/image interleaving.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
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;
|
||||
use super::preprocess::{merge_drop_caps, merge_heading_lines};
|
||||
use super::MarkdownOptions;
|
||||
|
||||
/// Pre-scan lines to find "isolated" ones: short lines with paragraph breaks both
|
||||
/// before and after. These are heading candidates even at body font size — common
|
||||
/// in academic papers ("Acknowledgements", "B.3 Prompt Engineering").
|
||||
fn find_isolated_lines(lines: &[TextLine], base_size: f32, para_threshold: f32) -> HashSet<usize> {
|
||||
let mut set = HashSet::new();
|
||||
for i in 0..lines.len() {
|
||||
let line = &lines[i];
|
||||
let plain = line.text();
|
||||
let trimmed = plain.trim();
|
||||
let word_count = trimmed.split_whitespace().count();
|
||||
if !(1..=6).contains(&word_count) || trimmed.len() <= 3 {
|
||||
continue;
|
||||
}
|
||||
let font_size = line.items.first().map(|it| it.font_size).unwrap_or(0.0);
|
||||
if font_size < base_size * 0.95 {
|
||||
continue;
|
||||
}
|
||||
if is_list_item(trimmed) || is_caption_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reject lines that look like wrapped paragraph text:
|
||||
// ends with hyphen, comma, preposition, or lowercase continuation
|
||||
let last_char = trimmed.chars().last().unwrap_or(' ');
|
||||
if last_char == '-' || last_char == ',' || last_char == ';' {
|
||||
continue;
|
||||
}
|
||||
// Last word is a common continuation word → wrapped paragraph
|
||||
let last_word = trimmed.split_whitespace().last().unwrap_or("");
|
||||
let continuation_words = [
|
||||
"the", "a", "an", "and", "or", "of", "in", "to", "for", "with", "by", "on", "at",
|
||||
"from", "as", "is", "are", "was", "were", "be", "that", "this", "their", "its", "our",
|
||||
"your", "has", "have", "had", "not",
|
||||
];
|
||||
if continuation_words.contains(&last_word.to_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph break BEFORE
|
||||
let break_before = if i == 0 {
|
||||
true
|
||||
} else {
|
||||
let prev = &lines[i - 1];
|
||||
prev.page != line.page || (prev.y - line.y).abs() > para_threshold
|
||||
};
|
||||
|
||||
// Paragraph break AFTER
|
||||
let break_after = if i + 1 >= lines.len() {
|
||||
true
|
||||
} else {
|
||||
let next = &lines[i + 1];
|
||||
next.page != line.page || (line.y - next.y).abs() > para_threshold
|
||||
};
|
||||
|
||||
if !break_before || !break_after {
|
||||
continue;
|
||||
}
|
||||
|
||||
set.insert(i);
|
||||
}
|
||||
|
||||
// Density guard: if too many lines on a page are "isolated", they're
|
||||
// all paragraph lines in a multi-column layout, not headings. Real
|
||||
// headings are rare — at most ~20% of lines on a page.
|
||||
let mut page_line_counts: HashMap<u32, (usize, usize)> = HashMap::new(); // (total, isolated)
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let entry = page_line_counts.entry(line.page).or_insert((0, 0));
|
||||
entry.0 += 1;
|
||||
if set.contains(&i) {
|
||||
entry.1 += 1;
|
||||
}
|
||||
}
|
||||
for (&page, &(total, isolated)) in &page_line_counts {
|
||||
if total > 0 && isolated as f32 / total as f32 > 0.25 {
|
||||
// Too many isolated lines on this page — remove them all
|
||||
set.retain(|&i| lines[i].page != page);
|
||||
}
|
||||
}
|
||||
|
||||
set
|
||||
}
|
||||
|
||||
/// Resolve the dominant structure role for a text line by looking up its items' MCIDs.
|
||||
///
|
||||
/// Returns the first non-container role found (skipping Document/Part/Sect/Div/NonStruct/Span).
|
||||
@@ -256,6 +338,13 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// threshold and cause every line to be treated as a paragraph break.
|
||||
let para_threshold = compute_paragraph_threshold(&lines, base_size);
|
||||
|
||||
// Pre-scan: identify isolated lines (paragraph break before AND after).
|
||||
// These are heading candidates even without bold/large font — common in
|
||||
// academic papers where section titles like "Acknowledgements" sit alone
|
||||
// between paragraphs at body font size. Inspired by opendataloader's
|
||||
// lookahead in HeadingProcessor (prevNode/nextNode context).
|
||||
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
|
||||
|
||||
let mut output = String::new();
|
||||
let mut current_page = 0u32;
|
||||
let mut prev_y = f32::MAX;
|
||||
@@ -277,7 +366,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
all_content_pages.sort();
|
||||
all_content_pages.dedup();
|
||||
|
||||
for line in lines {
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
// Page break
|
||||
if line.page != current_page {
|
||||
// Flush current page's remaining tables and images
|
||||
@@ -405,7 +494,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
|
||||
// Detect figure/table captions and source citations
|
||||
// These should be on their own line followed by a paragraph break
|
||||
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(&line, roles));
|
||||
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
|
||||
|
||||
// Determine if this line is code (struct-tree or font-based) for block accumulation
|
||||
let is_code_line = struct_role
|
||||
@@ -443,7 +532,42 @@ 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).
|
||||
// Heading probability scoring with lookahead context.
|
||||
// Score = rarity * 0.5 + bold * 0.3 + standalone * 0.2
|
||||
// + isolated * 0.3 (paragraph break before AND after)
|
||||
// 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 isolated = isolated_lines.contains(&line_idx);
|
||||
|
||||
let score = rarity * 0.5
|
||||
+ if all_bold { 0.3 } else { 0.0 }
|
||||
+ if standalone { 0.2 } else { 0.0 }
|
||||
+ if isolated { 0.3 } else { 0.0 };
|
||||
|
||||
// Require standalone + at least one strong signal.
|
||||
// Non-bold, non-isolated lines need very high rarity (≥0.97)
|
||||
// to avoid classifying ordinary body text as headings in
|
||||
// multi-column layouts where column switches break
|
||||
// paragraph continuity and minor font-size variation
|
||||
// inflates rarity scores.
|
||||
let has_strong_signal = all_bold || isolated || (rarity >= 0.97 && word_count <= 8);
|
||||
if score >= 0.5 && standalone && word_count >= 2 && has_strong_signal {
|
||||
Some(bold_heading_level(&heading_tiers))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -626,6 +750,8 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
// Compute the typical line spacing for paragraph break detection
|
||||
let para_threshold = compute_paragraph_threshold(&lines, base_size);
|
||||
|
||||
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
|
||||
|
||||
let mut output = String::new();
|
||||
let mut current_page = 0u32;
|
||||
let mut prev_y = f32::MAX;
|
||||
@@ -634,7 +760,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
let mut last_list_x: Option<f32> = None;
|
||||
let mut prev_had_dot_leaders = false;
|
||||
|
||||
for line in lines {
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
// Page break
|
||||
if line.page != current_page {
|
||||
if current_page > 0 {
|
||||
@@ -699,7 +825,27 @@ 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 isolated = isolated_lines.contains(&line_idx);
|
||||
let score = rarity * 0.5
|
||||
+ if all_bold { 0.3 } else { 0.0 }
|
||||
+ if standalone { 0.2 } else { 0.0 }
|
||||
+ if isolated { 0.3 } else { 0.0 };
|
||||
if score >= 0.5 && standalone && word_count >= 2 {
|
||||
return Some(bold_heading_level(&heading_tiers));
|
||||
}
|
||||
None
|
||||
})
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
@@ -1016,6 +1162,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rarity_heading_requires_strong_signal() {
|
||||
// Simulate a two-column academic paper where body text lines become
|
||||
// "standalone" due to column switches. Body text at the same font
|
||||
// size as most of the document should NOT be classified as headings
|
||||
// just because of moderate rarity + standalone.
|
||||
//
|
||||
// Regression: previously, lines with rarity ~0.62 and standalone=true
|
||||
// scored 0.51 (>=0.5 threshold), producing hundreds of false ## headings.
|
||||
|
||||
// Create many body-text lines at font_size=10.9 (most common)
|
||||
let mut lines = Vec::new();
|
||||
for i in 0..20 {
|
||||
let mut item = make_item("This is ordinary body text in a paragraph.", 1, None);
|
||||
item.font_size = 10.9;
|
||||
item.y = 700.0 - i as f32 * 14.0;
|
||||
lines.push(make_line(vec![item]));
|
||||
}
|
||||
// A few lines at a slightly different size (simulating column B text)
|
||||
for i in 0..10 {
|
||||
let mut item = make_item("Another body text line from the second column.", 1, None);
|
||||
item.font_size = 11.0; // slightly different → non-zero rarity
|
||||
item.y = 700.0 - i as f32 * 14.0;
|
||||
item.x = 320.0; // right column
|
||||
lines.push(make_line(vec![item]));
|
||||
}
|
||||
// One genuine bold heading
|
||||
let mut heading_item = make_item("3 Philosophical Perspectives", 1, None);
|
||||
heading_item.font_size = 10.9;
|
||||
heading_item.is_bold = true;
|
||||
heading_item.y = 200.0;
|
||||
lines.push(make_line(vec![heading_item]));
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
// The bold heading should be detected
|
||||
assert!(
|
||||
md.contains("## 3 Philosophical Perspectives"),
|
||||
"Bold heading should be detected: {md}"
|
||||
);
|
||||
|
||||
// Body text lines should NOT be headings
|
||||
let heading_count = md.lines().filter(|l| l.starts_with("##")).count();
|
||||
assert!(
|
||||
heading_count <= 2,
|
||||
"Expected at most 2 headings but found {heading_count} in:\n{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_code_multiline_accumulation() {
|
||||
let mut line1 = make_item("fn main() {", 1, Some(0));
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1650,7 +1650,7 @@ fn merge_cmaps(mut base: ToUnicodeCMap, overlay: ToUnicodeCMap) -> ToUnicodeCMap
|
||||
///
|
||||
/// Returns true if the median CID is >= 0x41 (letter 'A'), indicating
|
||||
/// the PDF generator likely used Unicode codepoints as CIDs.
|
||||
fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) -> bool {
|
||||
pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) -> bool {
|
||||
let w_arr = match cid_font_dict.get(b"W").ok() {
|
||||
Some(Object::Array(arr)) => arr,
|
||||
_ => return false,
|
||||
|
||||
@@ -1232,6 +1232,55 @@ fn test_extract_regions_mem_not_a_pdf() {
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,7 +76,7 @@ forms simpler, we would be happy to hear from you. You can write to the Tax Form
|
||||
|
||||
**Unreported Tips.—If you received tips of $20 or** more for any month while working for one employer but did not report them to your employer, you must figure and pay social security and Medicare taxes on the unreported tips when you file your tax return. If you have unreported tips, you must use Form 1040 and Form 4137, Social Security and Medicare Tax on Unreported Tip Income, to report them. You may not use Form 1040A or 1040EZ. Employees subject to the Railroad Retirement Tax Act cannot use Form 4137 to pay railroad retirement tax on unreported tips. To get railroad retirement credit, you must report tips to your employer. If you do not report tips to your employer as required, you may be charged a penalty of 50% of the social security and Medicare taxes (or railroad retirement tax) due on the unreported tips unless there was reasonable cause for not reporting them. **Additional Information.—Get Pub. 531, Reporting** Tip Income, and Form 4137 for more information on tips. If you are an employee of certain large food or beverage establishments, see Pub. 531 for tip allocation rules. **Recordkeeping.—If you do not keep a daily** record of tips, you must keep other reliable proof of the tip income you received. This proof includes copies of restaurant bills and credit card charges that show amounts customers added as tips. Keep your tip income records for as long as the information on them may be needed in the administration of any Internal Revenue law.
|
||||
|
||||
**Instructions (continued)**
|
||||
### Instructions (continued)
|
||||
|
||||
Use this space to total your tips for the year
|
||||
|
||||
|
||||
@@ -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|
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
**Technical Information**
|
||||
##### Technical Information
|
||||
|
||||
## l T-12 SI
|
||||
|
||||
DuPont Fluorochemicals
|
||||
##### DuPont Fluorochemicals
|
||||
|
||||
#### Thermodynamic Properties
|
||||
|
||||
@@ -20,25 +20,22 @@ Tables of the thermodynamic **Units** properties of R-12 have been developed and
|
||||
|
||||
S.A., Lemmon, E.W., and Peskin, Vf = Fluid (liquid) specific volume
|
||||
A.P., NIST Standard Reference in cubic meters per kilogram Database 23, NIST thermodynamic and transport properties of Vg = Vapour (gas) specific volume refrigerants and refrigerant in cubic meters per kilogram mixtures – REFPROP version 6.01, Standard Reference Data Program, df and dg = Fluid and Vapour National Institute of Standards and (respectively) densities in Technology, 1998). kilograms per cubic meter
|
||||
H = Enthalpy (kJ/kg)
|
||||
##### H = Enthalpy (kJ/kg)
|
||||
|
||||
S = Entropy (kJ/kg.K)
|
||||
##### S = Entropy (kJ/kg.K)
|
||||
|
||||
**Physical Properties**
|
||||
##### 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