Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5e76505f |
@@ -20,7 +20,17 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
@@ -51,9 +61,17 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: clippy
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-clippy-
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
@@ -71,9 +89,17 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: build
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-build-
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --verbose
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you've found a security vulnerability in pdf-inspector, please
|
||||
report it privately so we can fix it before public disclosure.
|
||||
|
||||
**Preferred:** Email **help@firecrawl.dev** with:
|
||||
|
||||
- A description of the issue and its impact
|
||||
- Steps to reproduce (a minimal PDF or input that triggers the bug is ideal)
|
||||
- The version or commit hash of pdf-inspector you tested against
|
||||
|
||||
**Alternative:** Use GitHub's private vulnerability reporting under the
|
||||
[Security tab](https://github.com/firecrawl/pdf-inspector/security/advisories/new).
|
||||
|
||||
We'll acknowledge your report in a timely manner and keep you updated on
|
||||
remediation progress. Please do not open a public GitHub issue for security
|
||||
bugs.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
- Memory-safety issues (panics, OOB reads, UB) reachable from a crafted PDF
|
||||
- Denial-of-service vectors (unbounded allocation, infinite loops) on
|
||||
reasonably-sized inputs
|
||||
- Bugs in the `pdf2md` / `detect-pdf` binaries or the `pdf-inspector` crate
|
||||
that affect downstream consumers
|
||||
|
||||
Out of scope:
|
||||
- Bugs in upstream dependencies (`lopdf`, etc.) — please report those upstream
|
||||
- Extraction quality issues (wrong text, missing tables) — open a regular
|
||||
GitHub issue instead
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@firecrawl/pdf-inspector",
|
||||
"version": "1.9.3",
|
||||
"version": "1.8.12",
|
||||
"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",
|
||||
|
||||
@@ -18,7 +18,7 @@ use super::fonts::{
|
||||
get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
|
||||
};
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, XObjectType};
|
||||
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
use super::{get_number, multiply_matrices};
|
||||
|
||||
/// Strip PDF comments (% to end of line) from content stream bytes.
|
||||
///
|
||||
@@ -664,29 +664,7 @@ pub(crate) fn extract_page_text_items(
|
||||
if let Some(xobj_type) = xobjects.get(&xobj_name) {
|
||||
match xobj_type {
|
||||
XObjectType::Image => {
|
||||
// Emit a positional placeholder for the image
|
||||
// so downstream consumers (layout-aware
|
||||
// pipelines, figure-OCR routers) can locate
|
||||
// raster figures without parsing the PDF
|
||||
// again. The text field carries the
|
||||
// XObject resource name in the legacy
|
||||
// `[Image: Im0]` format that the markdown
|
||||
// emitter already recognizes.
|
||||
let (x, y, width, height) = image_bbox_from_ctm(&ctm);
|
||||
items.push(TextItem {
|
||||
text: format!("[Image: {}]", xobj_name),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
font: String::new(),
|
||||
font_size: 0.0,
|
||||
page: page_num,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Image,
|
||||
mcid: current_mcid(&marked_content_stack),
|
||||
});
|
||||
// Skip images — text extraction only
|
||||
}
|
||||
XObjectType::Form(form_id) => {
|
||||
// Extract text from Form XObject
|
||||
|
||||
@@ -29,12 +29,8 @@ pub(crate) fn detect_columns(
|
||||
const MIN_ITEMS_PER_COLUMN: usize = 10;
|
||||
const NOISE_FRACTION: f32 = 0.15;
|
||||
|
||||
// Get items for this page. Strip Image placeholders — an image's left edge
|
||||
// would otherwise count toward the column projection profile.
|
||||
let page_items: Vec<&TextItem> = items
|
||||
.iter()
|
||||
.filter(|i| i.page == page && crate::extractor::is_text_layout_item(i))
|
||||
.collect();
|
||||
// Get items for this page
|
||||
let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect();
|
||||
|
||||
if page_items.is_empty() {
|
||||
return vec![];
|
||||
|
||||
@@ -227,69 +227,6 @@ fn extract_positioned_text_impl(
|
||||
// Shared helpers (used by submodules via `super::`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return true when this item should participate in text-layout
|
||||
/// heuristics (column detection, table grid detection, line grouping).
|
||||
///
|
||||
/// Image XObjects emit a positional placeholder via
|
||||
/// `extract_text_with_positions` (so layout-aware callers can crop +
|
||||
/// caption figures), but their bboxes don't carry text glyphs and would
|
||||
/// skew column/row clustering if they reached the heuristics. Hyperlinks
|
||||
/// and form fields *do* participate — the existing logic treats them as
|
||||
/// text-like and we keep that.
|
||||
pub(crate) fn is_text_layout_item(item: &crate::types::TextItem) -> bool {
|
||||
!matches!(item.item_type, crate::types::ItemType::Image)
|
||||
}
|
||||
|
||||
/// Map a (u, v) point in unit-square coordinates through the 6-element CTM
|
||||
/// to page-space. CTM format is `[a, b, c, d, e, f]` per
|
||||
/// [`multiply_matrices`].
|
||||
fn apply_ctm_point(ctm: &[f32; 6], u: f32, v: f32) -> (f32, f32) {
|
||||
(
|
||||
u * ctm[0] + v * ctm[2] + ctm[4],
|
||||
u * ctm[1] + v * ctm[3] + ctm[5],
|
||||
)
|
||||
}
|
||||
|
||||
/// Compute the page-space axis-aligned bounding box of an Image XObject
|
||||
/// invoked under the given CTM.
|
||||
///
|
||||
/// Per the PDF spec, an image XObject is always rendered into a unit
|
||||
/// square `(0,0)–(1,1)` in its local coordinate system, and the `Do`
|
||||
/// operator applies the current CTM to position/scale/rotate that square
|
||||
/// onto the page. For the common axis-aligned case (no rotation/shear),
|
||||
/// the CTM reduces to `[w, 0, 0, h, x, y]` and the bbox is just
|
||||
/// `(x, y, w, h)`. For rotated/sheared images we transform all four
|
||||
/// corners and return their axis-aligned bbox so the caller always gets
|
||||
/// an upright rectangle.
|
||||
///
|
||||
/// Coordinates are PDF user space (origin at bottom-left, y-up). Width
|
||||
/// and height are non-negative.
|
||||
pub(crate) fn image_bbox_from_ctm(ctm: &[f32; 6]) -> (f32, f32, f32, f32) {
|
||||
let corners = [
|
||||
apply_ctm_point(ctm, 0.0, 0.0),
|
||||
apply_ctm_point(ctm, 1.0, 0.0),
|
||||
apply_ctm_point(ctm, 1.0, 1.0),
|
||||
apply_ctm_point(ctm, 0.0, 1.0),
|
||||
];
|
||||
let (mut x_min, mut x_max) = (corners[0].0, corners[0].0);
|
||||
let (mut y_min, mut y_max) = (corners[0].1, corners[0].1);
|
||||
for (cx, cy) in corners.iter().skip(1) {
|
||||
if *cx < x_min {
|
||||
x_min = *cx;
|
||||
}
|
||||
if *cx > x_max {
|
||||
x_max = *cx;
|
||||
}
|
||||
if *cy < y_min {
|
||||
y_min = *cy;
|
||||
}
|
||||
if *cy > y_max {
|
||||
y_max = *cy;
|
||||
}
|
||||
}
|
||||
(x_min, y_min, x_max - x_min, y_max - y_min)
|
||||
}
|
||||
|
||||
/// Multiply two 2D transformation matrices
|
||||
/// Matrix format: [a, b, c, d, e, f] representing:
|
||||
/// | a b 0 |
|
||||
|
||||
+13
-37
@@ -10,7 +10,7 @@ use super::fonts::{
|
||||
build_font_encodings, build_font_widths, compute_string_width_ts, extract_text_from_operand,
|
||||
get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
|
||||
};
|
||||
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
use super::{get_number, multiply_matrices};
|
||||
|
||||
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
|
||||
|
||||
@@ -262,43 +262,19 @@ fn extract_form_xobject_text_inner(
|
||||
if !op.operands.is_empty() {
|
||||
if let Ok(name) = op.operands[0].as_name() {
|
||||
let xobj_name = String::from_utf8_lossy(name).to_string();
|
||||
match form_xobjects.get(&xobj_name) {
|
||||
Some(XObjectType::Form(nested_id)) => {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH {
|
||||
let nested_items = extract_form_xobject_text_inner(
|
||||
doc,
|
||||
*nested_id,
|
||||
page_num,
|
||||
font_cmaps,
|
||||
&ctm,
|
||||
cmap_decisions,
|
||||
depth + 1,
|
||||
);
|
||||
items.extend(nested_items);
|
||||
}
|
||||
if let Some(XObjectType::Form(nested_id)) = form_xobjects.get(&xobj_name) {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH {
|
||||
let nested_items = extract_form_xobject_text_inner(
|
||||
doc,
|
||||
*nested_id,
|
||||
page_num,
|
||||
font_cmaps,
|
||||
&ctm,
|
||||
cmap_decisions,
|
||||
depth + 1,
|
||||
);
|
||||
items.extend(nested_items);
|
||||
}
|
||||
Some(XObjectType::Image) => {
|
||||
// Mirror the top-level Image-XObject emission
|
||||
// in content_stream.rs so figures embedded
|
||||
// inside Form XObjects (common in print-to-PDF
|
||||
// workflows) aren't silently dropped.
|
||||
let (x, y, width, height) = image_bbox_from_ctm(&ctm);
|
||||
items.push(TextItem {
|
||||
text: format!("[Image: {}]", xobj_name),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
font: String::new(),
|
||||
font_size: 0.0,
|
||||
page: page_num,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Image,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
-1258
File diff suppressed because it is too large
Load Diff
+1
-10
@@ -422,16 +422,7 @@ impl Default for MarkdownOptions {
|
||||
fix_hyphenation: true,
|
||||
detect_bold: true,
|
||||
detect_italic: true,
|
||||
// `include_images: false` is intentional. The content-stream walker
|
||||
// now emits `ItemType::Image` `TextItem`s for every Image XObject
|
||||
// it encounters (see `extractor/content_stream.rs`). If we rendered
|
||||
// those into markdown by default, every existing caller would
|
||||
// suddenly see `` placeholders inserted
|
||||
// throughout their output — a silent regression for anyone who
|
||||
// upgrades. Image bboxes are still available via
|
||||
// `extract_text_with_positions` for callers (e.g. layout-aware
|
||||
// pipelines) that want to crop + caption figures themselves.
|
||||
include_images: false,
|
||||
include_images: true,
|
||||
include_links: true,
|
||||
include_page_numbers: false,
|
||||
strip_headers_footers: true,
|
||||
|
||||
@@ -231,15 +231,6 @@ pub fn detect_tables_from_rects(
|
||||
rects: &[PdfRect],
|
||||
page: u32,
|
||||
) -> (Vec<Table>, Vec<RectHintRegion>) {
|
||||
// Strip Image placeholders before column/row clustering — an image's bbox
|
||||
// would otherwise show up as a spurious column edge. See `is_text_layout_item`.
|
||||
let items_owned: Vec<TextItem> = items
|
||||
.iter()
|
||||
.filter(|i| crate::extractor::is_text_layout_item(i))
|
||||
.cloned()
|
||||
.collect();
|
||||
let items = items_owned.as_slice();
|
||||
|
||||
// Filter rects on this page; normalize negative widths/heights; skip tiny rects.
|
||||
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new(); // (x, y, w, h) normalized
|
||||
for r in rects {
|
||||
|
||||
+7
-269
@@ -160,81 +160,6 @@ fn starts_with_uppercase_word(cell: &str) -> bool {
|
||||
.is_some_and(|c| c.is_uppercase())
|
||||
}
|
||||
|
||||
fn starts_with_uppercase_alpha(cell: &str) -> bool {
|
||||
cell.chars()
|
||||
.find(|c| c.is_alphabetic())
|
||||
.is_some_and(|c| c.is_uppercase())
|
||||
}
|
||||
|
||||
fn starts_with_lowercase_alpha(cell: &str) -> bool {
|
||||
cell.chars()
|
||||
.find(|c| c.is_alphabetic())
|
||||
.is_some_and(|c| c.is_lowercase())
|
||||
}
|
||||
|
||||
fn starts_with_numbered_label(cell: &str) -> bool {
|
||||
let trimmed = cell.trim_start();
|
||||
let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
|
||||
digit_count > 0
|
||||
&& digit_count <= 3
|
||||
&& trimmed
|
||||
.chars()
|
||||
.nth(digit_count)
|
||||
.is_some_and(|c| matches!(c, '.' | ')' | '-' | ':'))
|
||||
}
|
||||
|
||||
fn alpha_word_count(cell: &str) -> usize {
|
||||
cell.split_whitespace()
|
||||
.filter(|word| word.chars().any(|c| c.is_alphabetic()))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn looks_like_compact_entry_label(cell: &str) -> bool {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.len() < 3 || trimmed.len() > 80 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !starts_with_uppercase_alpha(trimmed) && !starts_with_numbered_label(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if trimmed.ends_with(['.', ',', ';', ':']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let words = alpha_word_count(trimmed);
|
||||
(1..=6).contains(&words)
|
||||
}
|
||||
|
||||
fn looks_like_plain_section_label(cell: &str) -> bool {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.len() < 4 || trimmed.len() > 40 {
|
||||
return false;
|
||||
}
|
||||
if trimmed.ends_with(['.', ',', ';', ':']) || trimmed.contains(|ch: char| ch.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if trimmed.len() <= 4 && trimmed.chars().all(|ch| !ch.is_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_alphabetic() || ch.is_whitespace() || matches!(ch, '&' | '/' | '-'))
|
||||
&& starts_with_uppercase_alpha(trimmed)
|
||||
&& (1..=4).contains(&alpha_word_count(trimmed))
|
||||
}
|
||||
|
||||
fn ends_like_incomplete_phrase(cell: &str) -> bool {
|
||||
let lower = cell.trim_end().to_ascii_lowercase();
|
||||
lower.ends_with(" and")
|
||||
|| lower.ends_with(" or")
|
||||
|| lower.ends_with(',')
|
||||
|| lower.ends_with('-')
|
||||
|| lower.ends_with('/')
|
||||
}
|
||||
|
||||
/// Clean up table cells: merge continuation rows, extract footnotes, remove empty rows
|
||||
fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
let mut cleaned: Vec<Vec<String>> = Vec::new();
|
||||
@@ -260,9 +185,6 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let num_cols = row.len();
|
||||
let filled_cells = row.iter().filter(|c| !c.trim().is_empty()).count();
|
||||
|
||||
// Check if this is a continuation row (first column is empty but others have content).
|
||||
// A row with only 1 short non-empty cell (besides the first) is more likely a
|
||||
// section sub-header (e.g. "JAN", "FEB") than overflow text — don't merge it.
|
||||
@@ -300,76 +222,31 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
.iter()
|
||||
.filter(|cell| starts_with_uppercase_word(cell))
|
||||
.count();
|
||||
let first_non_empty_col = row.iter().position(|c| !c.trim().is_empty());
|
||||
let first_non_empty_cell = first_non_empty_col
|
||||
.and_then(|idx| row.get(idx))
|
||||
.map(|c| c.trim())
|
||||
.unwrap_or("");
|
||||
let title_like_later_cells = first_non_empty_col
|
||||
.map(|idx| {
|
||||
row.iter()
|
||||
.skip(idx + 1)
|
||||
.map(|c| c.trim())
|
||||
.filter(|c| !c.is_empty() && starts_with_uppercase_alpha(c))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let prev_first_cell_empty = cleaned
|
||||
.last()
|
||||
.and_then(|r| r.first())
|
||||
.is_some_and(|c| c.trim().is_empty());
|
||||
let prev_first_cell = cleaned
|
||||
.last()
|
||||
.and_then(|r| r.first())
|
||||
.map(|c| c.trim())
|
||||
.unwrap_or("");
|
||||
let header_filled = cleaned
|
||||
.first()
|
||||
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
|
||||
.unwrap_or(num_cols);
|
||||
let looks_like_spanning_first_column_row = first_cell.is_empty()
|
||||
&& row.len() >= 4
|
||||
&& non_first_cells.len() == row.len().saturating_sub(1)
|
||||
&& uppercase_leading_cells >= non_first_cells.len().saturating_sub(1);
|
||||
// Hierarchical tables often use a row-spanned first column: sub-rows
|
||||
// leave column 0 blank, then start a compact title-like label in
|
||||
// column 1. Wrapped continuations in the existing fixtures start
|
||||
// mid-sentence/lowercase ("continued text here", "with 3.5%...") or
|
||||
// carry lowercase fragments in the later cells, so keep those mergeable.
|
||||
let looks_like_hierarchical_subrow = first_cell.is_empty()
|
||||
&& row.len() >= 3
|
||||
&& first_non_empty_col == Some(1)
|
||||
&& looks_like_compact_entry_label(first_non_empty_cell)
|
||||
&& ((non_first_cells.len() >= 2 && title_like_later_cells > 0)
|
||||
|| (non_first_cells.len() == 1
|
||||
&& prev_first_cell_empty
|
||||
&& alpha_word_count(first_non_empty_cell) >= 2));
|
||||
let looks_like_new_first_column_entry = !first_cell.is_empty()
|
||||
&& (starts_with_numbered_label(first_cell) || starts_with_uppercase_alpha(first_cell))
|
||||
&& filled_cells >= 2
|
||||
&& non_first_cells
|
||||
.iter()
|
||||
.any(|cell| looks_like_compact_entry_label(cell));
|
||||
let looks_like_section_label_row = !first_cell.is_empty()
|
||||
&& filled_cells == 1
|
||||
&& header_filled >= 3
|
||||
&& looks_like_plain_section_label(first_cell);
|
||||
// Classic continuation: first cell empty, content in other cells
|
||||
let is_classic_continuation = first_cell.is_empty()
|
||||
&& !non_first_cells.is_empty()
|
||||
&& !is_short_subheader
|
||||
&& !looks_like_data_row
|
||||
&& !looks_like_spanning_first_column_row
|
||||
&& !looks_like_hierarchical_subrow
|
||||
&& cleaned.len() > 1;
|
||||
|
||||
// Wrapped-cell continuation: row has fewer filled cells than the header
|
||||
// row, suggesting it's overflow text from the previous row's cells.
|
||||
// Only trigger when the previous row has significantly more filled cells.
|
||||
let num_cols = row.len();
|
||||
let filled_cells = row.iter().filter(|c| !c.trim().is_empty()).count();
|
||||
let prev_filled = cleaned
|
||||
.last()
|
||||
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
|
||||
.unwrap_or(0);
|
||||
let header_filled = cleaned
|
||||
.first()
|
||||
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
|
||||
.unwrap_or(num_cols);
|
||||
// Merge when the row has significantly fewer filled cells than header.
|
||||
// For wide tables (5+ cols), require ≤50% of header cells.
|
||||
// For narrow tables (2-4 cols), require fewer than header cells.
|
||||
@@ -380,18 +257,11 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
} else {
|
||||
header_filled.saturating_sub(1)
|
||||
};
|
||||
let continues_wrapped_first_column_label = !first_cell.is_empty()
|
||||
&& starts_with_lowercase_alpha(first_cell)
|
||||
&& ends_like_incomplete_phrase(prev_first_cell);
|
||||
let is_wrapped_continuation = cleaned.len() > 1
|
||||
&& filled_cells <= max_filled_for_merge
|
||||
&& (prev_filled > filled_cells
|
||||
|| (continues_wrapped_first_column_label && prev_filled >= filled_cells))
|
||||
&& prev_filled > filled_cells
|
||||
&& !looks_like_data_row
|
||||
&& !looks_like_spanning_first_column_row
|
||||
&& !looks_like_hierarchical_subrow
|
||||
&& !looks_like_new_first_column_entry
|
||||
&& !looks_like_section_label_row
|
||||
&& !is_short_subheader;
|
||||
|
||||
let is_continuation = is_classic_continuation || is_wrapped_continuation;
|
||||
@@ -537,44 +407,6 @@ mod tests {
|
||||
assert!(cleaned[1][1].contains("continued text here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_first_column_section_label_not_merged() {
|
||||
let cells = vec![
|
||||
vec![
|
||||
"Properties".into(),
|
||||
"Conditions".into(),
|
||||
"Method".into(),
|
||||
"Typical values".into(),
|
||||
"Units".into(),
|
||||
],
|
||||
vec![
|
||||
"Melt Flow Rate".into(),
|
||||
"230 C/2.16 kg".into(),
|
||||
"ASTM D1238".into(),
|
||||
"3.0".into(),
|
||||
"g/10 min".into(),
|
||||
],
|
||||
vec![
|
||||
"Mechanical".into(),
|
||||
"".into(),
|
||||
"".into(),
|
||||
"".into(),
|
||||
"".into(),
|
||||
],
|
||||
vec![
|
||||
"Tensile Stress at Yield".into(),
|
||||
"50 mm/min".into(),
|
||||
"ASTM D638".into(),
|
||||
"31".into(),
|
||||
"MPa".into(),
|
||||
],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
|
||||
assert_eq!(cleaned.len(), 4);
|
||||
assert_eq!(cleaned[2][0], "Mechanical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_short_subheader_not_merged() {
|
||||
let cells = vec![
|
||||
@@ -627,100 +459,6 @@ mod tests {
|
||||
assert_eq!(cleaned[2][1], "Uncertainty around other copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_numbered_hierarchy_rows_not_overmerged() {
|
||||
let cells = vec![
|
||||
vec![
|
||||
"Group".into(),
|
||||
"Task".into(),
|
||||
"Detail".into(),
|
||||
"Benefit".into(),
|
||||
],
|
||||
vec![
|
||||
"1. Group alpha".into(),
|
||||
"Task setup and".into(),
|
||||
"Begin setup".into(),
|
||||
"Faster start".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"management".into(),
|
||||
"recommended profile".into(),
|
||||
"with saved defaults".into(),
|
||||
],
|
||||
vec![
|
||||
"2. Group beta and".into(),
|
||||
"Storage setup".into(),
|
||||
"Provides upload tools".into(),
|
||||
"".into(),
|
||||
],
|
||||
vec![
|
||||
"fine-tuning".into(),
|
||||
"".into(),
|
||||
"for filtered inputs".into(),
|
||||
"service".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"Label workspace".into(),
|
||||
"Creates review sets".into(),
|
||||
"Lets teams review".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"Model training".into(),
|
||||
"".into(),
|
||||
"Supports custom model".into(),
|
||||
],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
|
||||
assert_eq!(cleaned.len(), 5);
|
||||
assert_eq!(cleaned[1][0], "1. Group alpha");
|
||||
assert_eq!(cleaned[1][1], "Task setup and management");
|
||||
assert_eq!(cleaned[2][0], "2. Group beta and fine-tuning");
|
||||
assert_eq!(cleaned[2][1], "Storage setup");
|
||||
assert_eq!(cleaned[3][1], "Label workspace");
|
||||
assert_eq!(cleaned[4][1], "Model training");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_partial_hierarchical_subrow_not_merged() {
|
||||
let cells = vec![
|
||||
vec![
|
||||
"Group".into(),
|
||||
"Task".into(),
|
||||
"Detail".into(),
|
||||
"Benefit".into(),
|
||||
],
|
||||
vec![
|
||||
"Group A".into(),
|
||||
"Alpha task".into(),
|
||||
"Initial detail".into(),
|
||||
"Initial benefit".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"Beta task".into(),
|
||||
"Parallel detail".into(),
|
||||
"".into(),
|
||||
],
|
||||
vec![
|
||||
"".into(),
|
||||
"second line".into(),
|
||||
"additional detail".into(),
|
||||
"".into(),
|
||||
],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
|
||||
assert_eq!(cleaned.len(), 3);
|
||||
assert_eq!(cleaned[1][1], "Alpha task");
|
||||
assert_eq!(cleaned[2][0], "");
|
||||
assert_eq!(cleaned[2][1], "Beta task second line");
|
||||
assert_eq!(cleaned[2][2], "Parallel detail additional detail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_full_width_continuation_row_still_merges_when_lowercase() {
|
||||
let cells = vec![
|
||||
|
||||
@@ -460,7 +460,6 @@ pub(crate) fn try_build_table_from_columns(items: &[TextItem], page: u32) -> Opt
|
||||
item_indices.push(item_idx);
|
||||
}
|
||||
}
|
||||
merge_superscript_marker_rows(&mut row_ys, &mut cells);
|
||||
|
||||
// Validate: need reasonable fill rate
|
||||
let total_cells = row_ys.len() * columns.len();
|
||||
@@ -548,536 +547,6 @@ pub(crate) fn try_build_table_from_columns(items: &[TextItem], page: u32) -> Opt
|
||||
Some(Table::new(col_xs, row_ys, cells, item_indices))
|
||||
}
|
||||
|
||||
/// Build a region-scoped two-column key/value table from text baselines.
|
||||
///
|
||||
/// This intentionally lives outside the full-page heuristic detector. Layout
|
||||
/// callers already supplied a table-shaped bbox, and some real table regions
|
||||
/// are plain product/spec forms with only two visual columns. The main column
|
||||
/// fallback starts at four columns to avoid newspaper/prose false positives;
|
||||
/// this path keeps tighter key/value-specific guards instead.
|
||||
pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32) -> Option<Table> {
|
||||
let page_items: Vec<RowItem> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| item.page == page && !item.text.trim().is_empty())
|
||||
.map(|(idx, item)| RowItem {
|
||||
index: idx,
|
||||
item: item.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if page_items.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let median_font_size = median_f32(page_items.iter().map(|ri| ri.item.font_size).collect())
|
||||
.unwrap_or(10.0)
|
||||
.max(1.0);
|
||||
let y_tol = (median_font_size * 0.75).clamp(4.0, 9.0);
|
||||
let rows = group_key_value_visual_rows(page_items, y_tol);
|
||||
if rows.len() < 2 || rows.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let split_x = infer_key_value_split_x(&rows, median_font_size)?;
|
||||
let mut kv_rows: Vec<KeyValueRow> = Vec::new();
|
||||
let mut paired_rows = 0usize;
|
||||
let mut section_rows = 0usize;
|
||||
let mut left_label_like = 0usize;
|
||||
let mut left_starts = Vec::new();
|
||||
let mut right_starts = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
let mut left_items = Vec::new();
|
||||
let mut right_items = Vec::new();
|
||||
for item in &row.items {
|
||||
if item.item.x < split_x {
|
||||
left_items.push(item);
|
||||
} else {
|
||||
right_items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let left = join_row_item_text(&left_items);
|
||||
let right = join_row_item_text(&right_items);
|
||||
if left.is_empty() && right.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut item_indices: Vec<usize> = row.items.iter().map(|ri| ri.index).collect();
|
||||
item_indices.sort_unstable();
|
||||
item_indices.dedup();
|
||||
|
||||
if !left.is_empty() && !right.is_empty() {
|
||||
paired_rows += 1;
|
||||
if looks_like_key_value_label(&left) {
|
||||
left_label_like += 1;
|
||||
}
|
||||
if let Some(x) = left_items.first().map(|ri| ri.item.x) {
|
||||
left_starts.push(x);
|
||||
}
|
||||
if let Some(x) = right_items.first().map(|ri| ri.item.x) {
|
||||
right_starts.push(x);
|
||||
}
|
||||
} else if !left.is_empty() {
|
||||
section_rows += 1;
|
||||
}
|
||||
|
||||
kv_rows.push(KeyValueRow {
|
||||
y: row.y,
|
||||
left,
|
||||
right,
|
||||
item_indices,
|
||||
});
|
||||
}
|
||||
|
||||
if kv_rows.len() < 2 || paired_rows < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let header_inferred = key_value_first_pair_is_header(&kv_rows);
|
||||
let data_pairs = if header_inferred {
|
||||
paired_rows.saturating_sub(1)
|
||||
} else {
|
||||
paired_rows
|
||||
};
|
||||
if data_pairs < 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if section_rows > paired_rows * 2 + 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let label_rows_for_score = if header_inferred {
|
||||
paired_rows.saturating_sub(1)
|
||||
} else {
|
||||
paired_rows
|
||||
};
|
||||
let label_like_for_score = if header_inferred && !kv_rows.is_empty() {
|
||||
left_label_like.saturating_sub(1)
|
||||
} else {
|
||||
left_label_like
|
||||
};
|
||||
if label_rows_for_score >= 2 && label_like_for_score * 2 < label_rows_for_score {
|
||||
return None;
|
||||
}
|
||||
|
||||
let left_x = median_f32(left_starts).unwrap_or_else(|| {
|
||||
rows.iter()
|
||||
.flat_map(|row| row.items.iter().map(|ri| ri.item.x))
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
});
|
||||
let right_x = median_f32(right_starts).unwrap_or(split_x);
|
||||
if !left_x.is_finite() || !right_x.is_finite() || right_x - left_x < 40.0 {
|
||||
return None;
|
||||
}
|
||||
let right_cluster_count = significant_side_x_clusters(&rows, split_x, false);
|
||||
let marker_rows = marker_matrix_value_rows(&kv_rows);
|
||||
if (right_cluster_count >= 5 && paired_rows >= 3)
|
||||
|| (right_cluster_count >= 3 && marker_rows >= 3 && marker_rows * 2 >= paired_rows)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if key_value_rows_look_like_prose(&kv_rows, header_inferred) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut table_rows = Vec::new();
|
||||
let mut cells = Vec::new();
|
||||
let mut item_indices = Vec::new();
|
||||
|
||||
let mut start_idx = 0usize;
|
||||
if header_inferred {
|
||||
let header = &kv_rows[0];
|
||||
table_rows.push(header.y);
|
||||
cells.push(vec![header.left.clone(), header.right.clone()]);
|
||||
item_indices.extend(header.item_indices.iter().copied());
|
||||
start_idx = 1;
|
||||
} else {
|
||||
table_rows.push(kv_rows.first().map(|row| row.y + y_tol).unwrap_or(0.0));
|
||||
cells.push(vec!["Field".to_string(), "Value".to_string()]);
|
||||
}
|
||||
|
||||
for row in kv_rows.iter().skip(start_idx) {
|
||||
if !row.left.is_empty() && !row.right.is_empty() {
|
||||
table_rows.push(row.y);
|
||||
cells.push(vec![row.left.clone(), row.right.clone()]);
|
||||
item_indices.extend(row.item_indices.iter().copied());
|
||||
} else if !row.left.is_empty() {
|
||||
table_rows.push(row.y);
|
||||
cells.push(vec!["Section".to_string(), row.left.clone()]);
|
||||
item_indices.extend(row.item_indices.iter().copied());
|
||||
} else if !row.right.is_empty() {
|
||||
if let Some(last) = cells.last_mut() {
|
||||
if let Some(value) = last.get_mut(1) {
|
||||
if !value.trim().is_empty() {
|
||||
value.push(' ');
|
||||
}
|
||||
value.push_str(&row.right);
|
||||
item_indices.extend(row.item_indices.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cells.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
item_indices.sort_unstable();
|
||||
item_indices.dedup();
|
||||
|
||||
log::debug!(
|
||||
"key-value table: {} rows, pairs={}, sections={}, split_x={:.1}",
|
||||
cells.len(),
|
||||
paired_rows,
|
||||
section_rows,
|
||||
split_x
|
||||
);
|
||||
|
||||
Some(Table::new(
|
||||
vec![left_x, right_x],
|
||||
table_rows,
|
||||
cells,
|
||||
item_indices,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RowItem {
|
||||
index: usize,
|
||||
item: TextItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct VisualRow {
|
||||
y: f32,
|
||||
items: Vec<RowItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct KeyValueRow {
|
||||
y: f32,
|
||||
left: String,
|
||||
right: String,
|
||||
item_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
fn group_key_value_visual_rows(mut items: Vec<RowItem>, y_tol: f32) -> Vec<VisualRow> {
|
||||
items.sort_by(|a, b| {
|
||||
b.item
|
||||
.y
|
||||
.total_cmp(&a.item.y)
|
||||
.then_with(|| a.item.x.total_cmp(&b.item.x))
|
||||
});
|
||||
|
||||
let mut rows: Vec<VisualRow> = Vec::new();
|
||||
for row_item in items {
|
||||
if let Some(row) = rows
|
||||
.iter_mut()
|
||||
.find(|row| (row.y - row_item.item.y).abs() <= y_tol)
|
||||
{
|
||||
let len = row.items.len() as f32;
|
||||
row.y = (row.y * len + row_item.item.y) / (len + 1.0);
|
||||
row.items.push(row_item);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(VisualRow {
|
||||
y: row_item.item.y,
|
||||
items: vec![row_item],
|
||||
});
|
||||
}
|
||||
|
||||
for row in &mut rows {
|
||||
row.items.sort_by(|a, b| a.item.x.total_cmp(&b.item.x));
|
||||
}
|
||||
rows.sort_by(|a, b| b.y.total_cmp(&a.y));
|
||||
rows
|
||||
}
|
||||
|
||||
fn infer_key_value_split_x(rows: &[VisualRow], median_font_size: f32) -> Option<f32> {
|
||||
let min_gap = (median_font_size * 2.0).max(24.0);
|
||||
let mut splits = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
if row.items.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut best_gap = 0.0f32;
|
||||
let mut best_split = None;
|
||||
for pair in row.items.windows(2) {
|
||||
let left = &pair[0].item;
|
||||
let right = &pair[1].item;
|
||||
let left_right = left.x + left.width.max(0.0);
|
||||
let gap = right.x - left_right;
|
||||
if gap > best_gap {
|
||||
best_gap = gap;
|
||||
best_split = Some(left_right + gap / 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
if best_gap >= min_gap {
|
||||
if let Some(split) = best_split {
|
||||
splits.push(split);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if splits.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
median_f32(splits)
|
||||
}
|
||||
|
||||
fn join_row_item_text(items: &[&RowItem]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for item in items {
|
||||
let trimmed = item.item.text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
normalize_cell_text(&parts.join(" "))
|
||||
}
|
||||
|
||||
fn normalize_cell_text(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn key_value_first_pair_is_header(rows: &[KeyValueRow]) -> bool {
|
||||
let Some(first) = rows.first() else {
|
||||
return false;
|
||||
};
|
||||
if first.left.is_empty() || first.right.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !looks_like_key_value_header_cell(&first.left)
|
||||
|| !looks_like_key_value_header_cell(&first.right)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rows.iter()
|
||||
.skip(1)
|
||||
.any(|row| !row.left.is_empty() && !row.right.is_empty())
|
||||
}
|
||||
|
||||
fn looks_like_key_value_header_cell(cell: &str) -> bool {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.len() < 2 || trimmed.len() > 40 {
|
||||
return false;
|
||||
}
|
||||
let words = word_count_simple(trimmed);
|
||||
if !(1..=4).contains(&words) {
|
||||
return false;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if matches!(
|
||||
lower.as_str(),
|
||||
"yes" | "no" | "true" | "false" | "none" | "n/a" | "na"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
trimmed.chars().any(|c| c.is_alphabetic())
|
||||
&& !trimmed.chars().any(|c| c.is_ascii_digit())
|
||||
&& !trimmed.ends_with(['.', ',', ';', ':'])
|
||||
}
|
||||
|
||||
fn looks_like_key_value_label(cell: &str) -> bool {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.len() < 2 || trimmed.len() > 90 {
|
||||
return false;
|
||||
}
|
||||
let words = word_count_simple(trimmed);
|
||||
if words == 0 || words > 10 {
|
||||
return false;
|
||||
}
|
||||
if trimmed.ends_with(['.', ',', ';']) {
|
||||
return false;
|
||||
}
|
||||
trimmed.chars().any(|c| c.is_alphabetic())
|
||||
}
|
||||
|
||||
fn key_value_rows_look_like_prose(rows: &[KeyValueRow], header_inferred: bool) -> bool {
|
||||
let mut long_sentence_cells = 0usize;
|
||||
let mut total_cells = 0usize;
|
||||
let mut total_chars = 0usize;
|
||||
let mut paired_rows = 0usize;
|
||||
let mut solo_prose_rows = 0usize;
|
||||
|
||||
for row in rows.iter().skip(usize::from(header_inferred)) {
|
||||
if !row.left.is_empty() && !row.right.is_empty() {
|
||||
paired_rows += 1;
|
||||
} else {
|
||||
let solo = if row.left.is_empty() {
|
||||
row.right.trim()
|
||||
} else {
|
||||
row.left.trim()
|
||||
};
|
||||
if solo.chars().count() > 70
|
||||
|| word_count_simple(solo) > 9
|
||||
|| (solo.chars().count() > 35 && solo.ends_with(['.', '!', '?']))
|
||||
{
|
||||
solo_prose_rows += 1;
|
||||
}
|
||||
}
|
||||
for cell in [&row.left, &row.right] {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total_cells += 1;
|
||||
total_chars += trimmed.chars().count();
|
||||
if trimmed.chars().count() > 100
|
||||
|| (trimmed.chars().count() > 55 && trimmed.ends_with(['.', '!', '?']))
|
||||
{
|
||||
long_sentence_cells += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if paired_rows < 1 || total_cells == 0 {
|
||||
return true;
|
||||
}
|
||||
if solo_prose_rows >= 3 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let avg_chars = total_chars as f32 / total_cells as f32;
|
||||
avg_chars > 75.0 || long_sentence_cells * 2 >= total_cells
|
||||
}
|
||||
|
||||
fn marker_matrix_value_rows(rows: &[KeyValueRow]) -> usize {
|
||||
rows.iter()
|
||||
.filter(|row| !row.left.is_empty() && compact_marker_value(&row.right))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn compact_marker_value(cell: &str) -> bool {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.is_empty() || trimmed.chars().count() > 80 {
|
||||
return false;
|
||||
}
|
||||
if trimmed.chars().any(|ch| ch.is_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.any(|ch| ch.is_ascii_digit() || matches!(ch, '•' | '●' | '·'))
|
||||
}
|
||||
|
||||
fn significant_side_x_clusters(rows: &[VisualRow], split_x: f32, left_side: bool) -> usize {
|
||||
let mut xs = Vec::new();
|
||||
for row in rows {
|
||||
for item in &row.items {
|
||||
let is_left = item.item.x < split_x;
|
||||
if is_left == left_side {
|
||||
xs.push(item.item.x);
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
|
||||
let mut counts = Vec::new();
|
||||
let mut center = None::<f32>;
|
||||
let mut count = 0usize;
|
||||
for x in xs {
|
||||
match center {
|
||||
Some(current) if (x - current).abs() <= 8.0 => {
|
||||
center = Some((current * count as f32 + x) / (count as f32 + 1.0));
|
||||
count += 1;
|
||||
}
|
||||
Some(_) => {
|
||||
counts.push(count);
|
||||
center = Some(x);
|
||||
count = 1;
|
||||
}
|
||||
None => {
|
||||
center = Some(x);
|
||||
count = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
counts.push(count);
|
||||
}
|
||||
|
||||
counts.into_iter().filter(|&count| count >= 2).count()
|
||||
}
|
||||
|
||||
fn word_count_simple(cell: &str) -> usize {
|
||||
cell.split_whitespace()
|
||||
.filter(|word| word.chars().any(|c| c.is_alphanumeric()))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn median_f32(mut values: Vec<f32>) -> Option<f32> {
|
||||
values.retain(|value| value.is_finite());
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
values.sort_by(|a, b| a.total_cmp(b));
|
||||
Some(values[values.len() / 2])
|
||||
}
|
||||
|
||||
fn merge_superscript_marker_rows(row_ys: &mut Vec<f32>, cells: &mut Vec<Vec<String>>) {
|
||||
let mut row_idx = 0;
|
||||
while row_idx < cells.len() {
|
||||
let non_empty: Vec<(usize, String)> = cells[row_idx]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(col_idx, cell)| {
|
||||
let trimmed = cell.trim();
|
||||
(!trimmed.is_empty()).then_some((col_idx, trimmed.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if non_empty.len() != 1 || !is_superscript_marker_cell(&non_empty[0].1) {
|
||||
row_idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let (marker_col, marker) = &non_empty[0];
|
||||
let prev =
|
||||
(row_idx > 0).then(|| (row_idx - 1, (row_ys[row_idx - 1] - row_ys[row_idx]).abs()));
|
||||
let next = (row_idx + 1 < cells.len())
|
||||
.then(|| (row_idx + 1, (row_ys[row_idx] - row_ys[row_idx + 1]).abs()));
|
||||
let target = [prev, next]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(_, gap)| *gap <= 10.0)
|
||||
.min_by(|(_, gap_a), (_, gap_b)| gap_a.total_cmp(gap_b))
|
||||
.map(|(idx, _)| idx);
|
||||
|
||||
let Some(target_idx) = target else {
|
||||
row_idx += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let target_cell = &mut cells[target_idx][*marker_col];
|
||||
if target_cell.trim().is_empty() {
|
||||
*target_cell = marker.to_string();
|
||||
} else {
|
||||
target_cell.push_str(marker);
|
||||
}
|
||||
cells.remove(row_idx);
|
||||
row_ys.remove(row_idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_superscript_marker_cell(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
!trimmed.is_empty()
|
||||
&& trimmed.chars().count() <= 2
|
||||
&& trimmed
|
||||
.chars()
|
||||
.all(|ch| matches!(ch, '*' | '#' | 'o' | 'O' | '°' | 'º' | '†' | '‡'))
|
||||
}
|
||||
|
||||
/// What kind of structure a detected `Table` represents. Classification is
|
||||
/// computed once at construction so consumers don't have to re-analyze the
|
||||
/// cells (and stay consistent across detection backends).
|
||||
@@ -1220,190 +689,6 @@ mod tests {
|
||||
assert!(md.contains("|Cell 1|"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_superscript_marker_rows() {
|
||||
let mut rows = vec![506.0, 500.0, 480.0];
|
||||
let mut cells = vec![
|
||||
vec!["".into(), "".into(), "*".into()],
|
||||
vec!["Name".into(), "Method".into(), "Typical values".into()],
|
||||
vec!["Flow".into(), "ASTM D1238".into(), "3.0".into()],
|
||||
];
|
||||
|
||||
merge_superscript_marker_rows(&mut rows, &mut cells);
|
||||
|
||||
assert_eq!(rows, vec![500.0, 480.0]);
|
||||
assert_eq!(cells[0][2], "Typical values*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_builder_handles_borderless_specs_table() {
|
||||
let items = vec![
|
||||
make_char("*", 458.1, 544.2, 8.0, 4.4),
|
||||
make_char("Properties", 36.0, 538.6, 12.0, 53.1),
|
||||
make_char("Conditions", 195.8, 538.6, 12.0, 55.0),
|
||||
make_char("Method", 297.2, 538.6, 12.0, 39.4),
|
||||
make_char("Typical values", 384.1, 538.6, 12.0, 74.0),
|
||||
make_char("Units", 510.6, 538.6, 8.0, 17.9),
|
||||
make_char("Rheology", 36.0, 508.3, 10.0, 40.6),
|
||||
make_char("o", 209.8, 492.5, 6.5, 3.5),
|
||||
make_char("Melt Flow Rate", 36.0, 488.0, 10.0, 65.2),
|
||||
make_char("230 ", 190.4, 488.0, 10.0, 19.4),
|
||||
make_char("C/2.16 kg", 213.3, 488.0, 10.0, 42.8),
|
||||
make_char("ASTM D1238", 288.4, 488.0, 10.0, 56.8),
|
||||
make_char("3.0 ", 416.4, 488.0, 10.0, 16.9),
|
||||
make_char("g/10 min", 504.1, 488.0, 10.0, 39.5),
|
||||
make_char("Mechanical", 36.0, 451.5, 10.0, 48.3),
|
||||
make_char("Tensile Stress at Yield", 36.0, 431.3, 10.0, 96.7),
|
||||
make_char("50 mm/min", 197.9, 431.3, 10.0, 50.8),
|
||||
make_char("ASTM D638", 291.2, 431.3, 10.0, 51.3),
|
||||
make_char("31 ", 417.9, 431.3, 10.0, 13.9),
|
||||
make_char("MPa", 514.7, 431.3, 10.0, 18.4),
|
||||
make_char("Elongation at Yield", 36.0, 403.0, 10.0, 82.2),
|
||||
make_char("50 mm/min", 197.9, 403.0, 10.0, 50.8),
|
||||
make_char("ASTM D638", 291.2, 403.0, 10.0, 51.3),
|
||||
make_char("8 ", 420.6, 403.0, 10.0, 8.5),
|
||||
make_char("%", 519.1, 403.0, 10.0, 9.7),
|
||||
make_char("Flexural Modulus", 36.0, 374.6, 10.0, 74.0),
|
||||
make_char("ASTM D790", 291.2, 374.6, 10.0, 51.3),
|
||||
make_char("1400", 412.4, 374.6, 10.0, 21.8),
|
||||
make_char("MPa", 514.7, 374.6, 10.0, 18.4),
|
||||
];
|
||||
|
||||
let table = try_build_table_from_columns(&items, 1).unwrap();
|
||||
let md = table_to_markdown(&table);
|
||||
|
||||
assert!(
|
||||
md.contains("|Properties|Conditions|Method|Typical values*|Units|"),
|
||||
"{md}"
|
||||
);
|
||||
assert!(md.contains("|Mechanical|||||"), "{md}");
|
||||
assert!(
|
||||
md.contains("|Flexural Modulus||ASTM D790|1400|MPa|"),
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_value_builder_recovers_sectioned_specs_table() {
|
||||
let items = vec![
|
||||
make_char("Ordering Information", 69.0, 700.0, 9.0, 96.0),
|
||||
make_char("Package Contents", 69.0, 680.0, 9.0, 82.0),
|
||||
make_char(
|
||||
"CCH Adapter Panel with 3 m pigtail; installation guide",
|
||||
200.0,
|
||||
680.0,
|
||||
9.0,
|
||||
245.0,
|
||||
),
|
||||
make_char("Units per Delivery", 69.0, 660.0, 9.0, 78.0),
|
||||
make_char("1/1", 200.0, 660.0, 9.0, 18.0),
|
||||
];
|
||||
|
||||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||
let md = table_to_markdown(&table);
|
||||
|
||||
assert!(md.contains("|Field|Value|"), "{md}");
|
||||
assert!(md.contains("|Section|Ordering Information|"), "{md}");
|
||||
assert!(
|
||||
md.contains(
|
||||
"|Package Contents|CCH Adapter Panel with 3 m pigtail; installation guide|"
|
||||
),
|
||||
"{md}"
|
||||
);
|
||||
assert!(md.contains("|Units per Delivery|1/1|"), "{md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_value_builder_preserves_two_column_header() {
|
||||
let items = vec![
|
||||
make_char("Media", 86.0, 700.0, 10.0, 36.0),
|
||||
make_char("Options", 311.0, 700.0, 10.0, 44.0),
|
||||
make_char("BACnet/IP (Annex J)", 86.0, 680.0, 10.0, 115.0),
|
||||
make_char("Register as Foreign Device", 311.0, 680.0, 10.0, 138.0),
|
||||
];
|
||||
|
||||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||
let md = table_to_markdown(&table);
|
||||
|
||||
assert!(md.starts_with("|Media|Options|"), "{md}");
|
||||
assert!(
|
||||
md.contains("|BACnet/IP (Annex J)|Register as Foreign Device|"),
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_value_builder_keeps_repeated_spec_sections() {
|
||||
let items = vec![
|
||||
make_char("1.33 DUAL VVT-i", 90.0, 700.0, 9.0, 82.0),
|
||||
make_char("Engine Code", 90.0, 682.0, 9.0, 62.0),
|
||||
make_char("1NR-FE", 406.0, 682.0, 9.0, 42.0),
|
||||
make_char("Type", 90.0, 664.0, 9.0, 24.0),
|
||||
make_char("Four cylinders in-line", 376.0, 664.0, 9.0, 104.0),
|
||||
make_char("1.6 VALVEMATIC", 90.0, 636.0, 9.0, 78.0),
|
||||
make_char("Engine Code", 90.0, 618.0, 9.0, 62.0),
|
||||
make_char("1ZR-FAE", 404.0, 618.0, 9.0, 44.0),
|
||||
];
|
||||
|
||||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||
let md = table_to_markdown(&table);
|
||||
|
||||
assert!(md.contains("|Section|1.33 DUAL VVT-i|"), "{md}");
|
||||
assert!(md.contains("|Engine Code|1NR-FE|"), "{md}");
|
||||
assert!(md.contains("|Section|1.6 VALVEMATIC|"), "{md}");
|
||||
assert!(md.contains("|Engine Code|1ZR-FAE|"), "{md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_value_builder_rejects_split_prose() {
|
||||
let items = vec![
|
||||
make_char(
|
||||
"This paragraph describes an operational process and continues without a field label.",
|
||||
70.0,
|
||||
700.0,
|
||||
10.0,
|
||||
350.0,
|
||||
),
|
||||
make_char(
|
||||
"It was split only because the text wrapped across a wide line.",
|
||||
455.0,
|
||||
700.0,
|
||||
10.0,
|
||||
300.0,
|
||||
),
|
||||
make_char(
|
||||
"Another sentence explains background context rather than a measurable property.",
|
||||
70.0,
|
||||
680.0,
|
||||
10.0,
|
||||
350.0,
|
||||
),
|
||||
make_char(
|
||||
"The neighboring phrase is not a value and should not form a table.",
|
||||
455.0,
|
||||
680.0,
|
||||
10.0,
|
||||
300.0,
|
||||
),
|
||||
make_char(
|
||||
"Finally, this narrative line keeps flowing with normal prose content.",
|
||||
70.0,
|
||||
660.0,
|
||||
10.0,
|
||||
350.0,
|
||||
),
|
||||
make_char(
|
||||
"It has punctuation and complete sentences on both sides of the gap.",
|
||||
455.0,
|
||||
660.0,
|
||||
10.0,
|
||||
300.0,
|
||||
),
|
||||
];
|
||||
|
||||
assert!(try_build_key_value_table_from_rows(&items, 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_font_table_detected() {
|
||||
let items = vec![
|
||||
|
||||
+3
-187
@@ -2,14 +2,13 @@
|
||||
|
||||
use pdf_inspector::detector::{estimate_page_count_from_bytes, DetectionConfig, ScanStrategy};
|
||||
use pdf_inspector::extractor::group_into_lines;
|
||||
use pdf_inspector::types::ItemType;
|
||||
use pdf_inspector::types::TextLine;
|
||||
use pdf_inspector::{
|
||||
detect_pdf_type, detect_vector_grid_in_region_mem, extract_pages_markdown,
|
||||
extract_pages_markdown_mem, extract_tables_in_regions_mem, extract_text,
|
||||
extract_text_in_regions_mem, extract_text_with_positions, extract_text_with_positions_mem,
|
||||
process_pdf_mem, process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions,
|
||||
PdfType, TextItem,
|
||||
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;
|
||||
|
||||
@@ -3392,186 +3391,3 @@ fn test_synthetic_type0_broken_tounicode_emits_fffd_not_latin1_mojibake() {
|
||||
result.pages_needing_ocr
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image XObject emission
|
||||
// ============================================================================
|
||||
|
||||
/// Build a minimal PDF containing one Image XObject placed at a known CTM.
|
||||
/// `image_ctm` is the 6-element matrix applied to the unit square by the
|
||||
/// `Do` operator (per PDF spec section 8.9.5 "Image Coordinate System").
|
||||
/// For an axis-aligned image at `(x, y)` with size `w × h`, that's
|
||||
/// `[w, 0, 0, h, x, y]`.
|
||||
fn make_pdf_with_image(image_ctm: [f32; 6]) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
fn add_stream_object(
|
||||
pdf: &mut Vec<u8>,
|
||||
offsets: &mut Vec<usize>,
|
||||
id: usize,
|
||||
dict: &str,
|
||||
stream_bytes: &[u8],
|
||||
) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(
|
||||
format!("<< {} /Length {} >>\nstream\n", dict, stream_bytes.len()).as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(stream_bytes);
|
||||
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
// 1: catalog → 2: pages → 3: page with XObject /Im0 → 4: content stream
|
||||
// 5: font → 6: image XObject (1×1 grayscale)
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
2,
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
|
||||
/Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> \
|
||||
/Contents 4 0 R >>",
|
||||
);
|
||||
let [a, b, c, d, e, f] = image_ctm;
|
||||
// BT/ET around a small text item just so the page isn't classified as
|
||||
// image-only (which would route to a different code path). Then save
|
||||
// graphics state, apply the image CTM, invoke Im0, restore.
|
||||
let content = format!(
|
||||
"BT /F1 12 Tf 100 700 Td (Hi) Tj ET\nq {} {} {} {} {} {} cm /Im0 Do Q",
|
||||
a, b, c, d, e, f
|
||||
);
|
||||
add_stream_object(&mut pdf, &mut offsets, 4, "", content.as_bytes());
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
5,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
);
|
||||
// 1×1 grayscale image; the single byte is mid-gray. Contents don't
|
||||
// matter to the extractor — it only cares about the XObject's
|
||||
// /Subtype and the CTM at the `Do` operator.
|
||||
let image_pixel = [128u8];
|
||||
add_stream_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
6,
|
||||
"/Type /XObject /Subtype /Image /Width 1 /Height 1 \
|
||||
/ColorSpace /DeviceGray /BitsPerComponent 8",
|
||||
&image_pixel,
|
||||
);
|
||||
|
||||
let xref_start = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for offset in offsets.iter().skip(1) {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
||||
offsets.len(),
|
||||
xref_start
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_text_with_positions_emits_image_bboxes() {
|
||||
// Place a 200×100 image at (50, 600) in PDF user space (origin
|
||||
// bottom-left). The Do operator applies the CTM to a unit square,
|
||||
// so for an axis-aligned image, CTM = [w, 0, 0, h, x, y].
|
||||
let pdf = make_pdf_with_image([200.0, 0.0, 0.0, 100.0, 50.0, 600.0]);
|
||||
let items = extract_text_with_positions_mem(&pdf).expect("extract");
|
||||
|
||||
let images: Vec<&TextItem> = items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.item_type, ItemType::Image))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"expected exactly one Image item, got items: {:?}",
|
||||
items
|
||||
.iter()
|
||||
.map(|i| (&i.text, &i.item_type))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let img = images[0];
|
||||
assert!((img.x - 50.0).abs() < 0.01, "x={}", img.x);
|
||||
assert!((img.y - 600.0).abs() < 0.01, "y={}", img.y);
|
||||
assert!((img.width - 200.0).abs() < 0.01, "width={}", img.width);
|
||||
assert!((img.height - 100.0).abs() < 0.01, "height={}", img.height);
|
||||
assert_eq!(img.page, 1);
|
||||
// text field carries the legacy `[Image: <resource-name>]` form that
|
||||
// the markdown emitter already knows how to parse.
|
||||
assert_eq!(img.text, "[Image: Im0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_xobject_bbox_handles_rotated_ctm() {
|
||||
// 90° rotation CTM: a unit square at the origin maps to a square
|
||||
// rotated counter-clockwise about (0,0), then translated to (200, 300).
|
||||
// For a 100×100 image, that's CTM = [0, 100, -100, 0, 200, 300]
|
||||
// (apply the rotation: (1,0) → (0,100); (0,1) → (-100,0)).
|
||||
// The page-space corners are:
|
||||
// (0,0) → (200, 300)
|
||||
// (1,0) → (200, 400)
|
||||
// (1,1) → (100, 400)
|
||||
// (0,1) → (100, 300)
|
||||
// → AABB: x=100..200 (w=100), y=300..400 (h=100).
|
||||
let pdf = make_pdf_with_image([0.0, 100.0, -100.0, 0.0, 200.0, 300.0]);
|
||||
let items = extract_text_with_positions_mem(&pdf).expect("extract");
|
||||
let img = items
|
||||
.iter()
|
||||
.find(|i| matches!(i.item_type, ItemType::Image))
|
||||
.expect("image item");
|
||||
assert!((img.x - 100.0).abs() < 0.01, "x={}", img.x);
|
||||
assert!((img.y - 300.0).abs() < 0.01, "y={}", img.y);
|
||||
assert!((img.width - 100.0).abs() < 0.01, "width={}", img.width);
|
||||
assert!((img.height - 100.0).abs() < 0.01, "height={}", img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_emission_does_not_change_default_markdown() {
|
||||
// Default `MarkdownOptions::include_images = false` — adding image
|
||||
// emission MUST NOT make `extract_pages_markdown` start producing
|
||||
// `![Image: …]` placeholders for everyone. Existing callers that
|
||||
// upgrade should see no diff in their markdown.
|
||||
let pdf = make_pdf_with_image([200.0, 0.0, 0.0, 100.0, 50.0, 600.0]);
|
||||
let result = extract_pages_markdown_mem(&pdf, None).expect("extract");
|
||||
assert_eq!(result.pages.len(), 1);
|
||||
assert!(
|
||||
!result.pages[0].markdown.contains("Image:"),
|
||||
"default markdown leaked an image placeholder: {:?}",
|
||||
result.pages[0].markdown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_options_default_has_include_images_false() {
|
||||
// Explicit assertion so anyone flipping this back catches it in CI.
|
||||
// See `MarkdownOptions::default` in src/markdown/mod.rs for the
|
||||
// long-form rationale.
|
||||
let opts = MarkdownOptions::default();
|
||||
assert!(!opts.include_images);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user