Compare commits

..
Author SHA1 Message Date
Abimael Martell 3081f94e72 fix(pdf-inspector): recover key-value region tables 2026-05-29 14:38:56 -07:00
Abimael Martell 455dfe5a74 fix(pdf-inspector): recover borderless region tables (#97) 2026-05-28 10:20:06 -07:00
Abimael Martell 839317525b fix(pdf-inspector): relax vector table confidence gates (#96) 2026-05-27 11:45:00 -07:00
Abimael Martell 8b63ceb084 emit ItemType::Image bboxes for Image XObjects (was: silently dropped) (#94)
* emit ItemType::Image bboxes for Image XObjects (was: silently dropped)

Background. ItemType::Image, MarkdownOptions::include_images, and the
markdown emitter's image-collection path have all been in the tree
for a while, but no producer ever populated them — content_stream.rs
explicitly `// Skip images — text extraction only` at the Do
operator, and the nested Form-XObject walker in xobjects.rs only
matched XObjectType::Form, silently dropping Image entries. The
declared types were dead code.

This PR lights them up. At every Do that resolves to an Image
XObject (both top-level and nested inside Form XObjects), we now
compute the page-space bbox from the current CTM via a new
`image_bbox_from_ctm` helper — handling both axis-aligned and
rotated/sheared placements via 4-corner AABB — and emit a TextItem
with `item_type: ItemType::Image` and the legacy `[Image: <name>]`
text payload that the markdown emitter already knows how to render.

Callers can now find raster figures via `extract_text_with_positions`
(and the `_mem` variant, newly re-exported at the crate root) without
needing to re-parse the PDF or run a vision/layout model. The intended
consumer is layout-aware text pipelines that want to crop figures and
caption them out-of-band.

Two backstops to avoid silent breakage for existing callers:

  1. `MarkdownOptions::include_images` default flipped `true → false`.
     If it stayed at `true`, every existing user of
     `extract_pages_markdown` would suddenly see `![Image: Im0](image)`
     placeholders inserted throughout their output the moment they
     upgraded. Image data is still available structurally via
     `extract_text_with_positions`; rendering it into markdown is now
     an opt-in. New regression test asserts `extract_pages_markdown`
     output is unchanged for the image-bearing fixture.

  2. Image items now also skip the layout heuristics
     (`detect_columns`, `detect_tables_from_rects`) via a new
     `is_text_layout_item` predicate. Without this filter, an image's
     left edge would land in the column-projection profile and skew
     table column detection — surfaced by
     `vector_grid_tests::upstage_key_functions_four_cols` going from 4
     detected columns to 5 in CI before the filter was added.

Re-exporting `extract_text_with_positions_mem` at the crate root —
strictly additive; mirrors how `extract_pages_markdown_mem` is already
available there.

Tests:

  - test_extract_text_with_positions_emits_image_bboxes — minimal PDF
    with one 200×100 image at (50, 600); asserts one Image item with
    correct bbox + page + text.
  - test_image_xobject_bbox_handles_rotated_ctm — 90° rotated image
    via shear-component CTM; asserts AABB is correct (handles non-
    axis-aligned placements via 4-corner clamp).
  - test_image_emission_does_not_change_default_markdown — asserts no
    `Image:` token leaks into default markdown output, regression
    guard for the include_images flip.
  - test_markdown_options_default_has_include_images_false — explicit
    sentinel so anyone flipping it back catches it in CI.

* Bump version from 1.8.15 to 1.9.0
2026-05-20 13:43:05 -07:00
Abimael MartellandClaude Opus 4.7 73cffed1da detect_tables: catch narrow-column undercount via text-cluster topology (#93)
text_cluster_column_undercount previously fired only on tables with
6+ markdown columns. That missed a common production failure shape:
4-column page geometry where the heuristic detector's x-position
clustering collapses 2 narrow numeric columns (dates, IDs, amounts)
into adjacent wider columns, producing 2-column markdown.

The original 6-column floor existed because raw x-cluster count is
noisy on small tables — wrapped continuations, bullet indents, and
within-cell text variation produce many small x-clusters that don't
correspond to real columns. Examples:

  - Pcmso-style 2-column key/value layout with multi-line values
    shows 10 raw x-clusters but only 1 has more than a single item.
  - Yale-style archival catalog with 3 columns shows 6 raw
    x-clusters but most clusters are single-item continuations.

Replace the raw cluster count with a "significant cluster" count:
clusters whose item count is at least 1/4 of the dominant cluster
(and ≥2 items). That filters the within-cell-variation noise while
preserving signal from real columns, which consistently have one
item per row.

With significant-cluster counting:
  - Narrow-undercount fires when significant_clusters >= 3 AND
    significant_clusters >= table_cols * 2 (catches 4-col-collapsed-
    to-2 cases without misfiring on pcmso 10-clusters or yale 6
    where the significant subset matches markdown).
  - Legacy wide-undercount path (table_cols >= 6) keeps the same
    +2 / 1.2x thresholds, now applied to significant clusters
    instead of raw clusters.

Verified against representative regression and must-not-regress
cases from prior PR cycles: narrow-undercount catches genuine
column-drop cases (significant=4, markdown=2 → routes to OCR);
must-not-regress fixtures (italian-gov 6520 chars, pcmso 1733
chars, yale 1754 chars, doc200/182/189 still routing to OCR via
PR #91 guards) unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:26:35 -07:00
Abimael MartellandClaude Opus 4.7 647ea5c7cd extract_tables: reject font-decode failures via text-density floor (#92)
Some PDFs have fonts whose ToUnicode CMap is missing or broken —
Identity-H fonts without unicode metadata, Type-3 fonts where every
glyph maps to garbage. The page extractor returns punctuation-only
fragments or single-glyph repeats; the rendered image still carries
the visible text, so the region should fall back to OCR rather than
serve a partial table.

The existing captured_only_a_fragment guard can't catch this case
because region_text_chars itself collapses under font-decode failure —
captured vs extracted is symmetrically low, and the ratio still looks
acceptable.

Add a complementary area-based density guard: when a region has lots
of pixel real estate but very few text chars, the page extractor
hit a font failure. Bbox area is independent of extraction success,
so the symmetry breaks.

Threshold 0.003 chars/sq pt sits between observed clean extractions
(≥0.005 on full-page A4 ledgers, key/value layouts, archival
catalogs) and observed font-decode failures (≤0.0014 on prod-traffic
samples). Three guards keep it from misfiring:
  - text_chars < 20 skipped: synthetic / fragmentary fixtures
  - area < 30,000 sq pt skipped: tiny stat blocks
  - area > 400,000 sq pt skipped: near-whole-A4 bboxes where
    density is unreliable (large white-space margins)

Verified against three reproducible cases from prod shadow logs
that previously served partial output:
  - Cyrillic page with punctuation-only decode (46 chars, density
    0.00045) → flagged, routes to OCR
  - Cyrillic page where every glyph collapsed to one letter (89
    chars, density 0.00025) → flagged, routes to OCR
  - Materials-test region where text extracted fine but the table
    body extends beyond the bbox (96 chars, density 0.00131)
    → flagged, routes to OCR

Existing fixtures (full-page A4 ledger, multi-row key/value with
paragraph values, archival catalog, bits_pilani whole-page tests,
synthetic line-grid test) all retain identical behavior.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:51:23 -04:00
Abimael Martell 36445595d0 Bump version from 1.8.12 to 1.8.13 2026-05-14 22:30:38 -04:00
Abimael Martell 0932ce331b [codex] harden native table selection against undercounts (#91)
* fix hierarchical table row merging

* fix table undercount prose fragments
2026-05-14 22:09:09 -04:00
Abimael MartellandClaude Opus 4.7 a6e59de8f0 ci: switch to Swatinem/rust-cache to fix macOS build (#90)
Caching ~/.cargo/bin/ via actions/cache@v4 was poisoning the cargo
shim on macOS runners — restored cargo resolved to rustup-init and
failed with "unexpected argument 'build' found". Swatinem/rust-cache
skips that directory and handles target/ pruning + key derivation.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:08:28 -04:00
Abimael MartellandClaude Opus 4.7 b22bd759ab Add SECURITY.md with private vulnerability reporting policy (#89)
Documents how to report security issues privately (help@firecrawl.dev
or GitHub's private advisory flow) and what is in/out of scope, so
researchers don't disclose publicly via GitHub issues.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:01:39 -04:00
Abimael MartellandClaude Opus 4.7 7539868bf8 extract_tables: reject partial extractions in needs_ocr gate (#87)
After enabling the vector-grid detectors on the extract path (#85)
and broadening detection to full-page grids (#83), long-cell tables
(#84), and segment-only layouts (#86), one residual failure shape
remained: detectors finding a valid grid but only capturing a small
fraction of the region's actual text. Two recurring sub-shapes:

  - "header-only": detector captured the column-header band (often a
    multi-line year/units block) but missed every data row below.
    Common in financial statements, securities tables, budget
    appendices.
  - "sparse": detector returned a handful of fragmentary cells from a
    content-rich region, missing the bulk of the page.

Both pass the existing needs_ocr quality gates — the captured cells
are well-formed markdown — but the customer would receive a 5-row
fragment of a 50-row table. Today these regions fell back to GLM-OCR
by default; flipping `__nativeTableExtraction=true` would start
serving the partials.

Add `captured_only_a_fragment(md, region_text_chars)`: rejects when
the captured non-delimiter character count is less than 25% of the
text the page extractor saw inside the region. The 200-char region
floor keeps short legitimate tables (units, axis labels) from being
mis-flagged. Wired into the existing `evaluate` quality gate
alongside is_garbage_text / is_cid_garbage / detect_encoding_issues
/ looks_like_partial_table_ex.

Verified against three representative residual cases from shadow
logs (financial-statement header band, securities-table fragment,
ESIA sparse region): all flip from `needs_ocr=false` with partial
output to `needs_ocr=true` so GLM takes over. Existing full-table
fixtures (governmental ledger, PPRA-style key/value, archival
catalog) still pass through unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:20:02 -04:00
Abimael MartellandClaude Opus 4.7 79d75dbdca detect_lines: derive column edges from horizontal-segment endpoints (#86)
Some catalog and archival-finding-aid tables draw each row's
horizontal rule as N segments (one segment per cell) with no vertical
lines at all. The previous detector rejected these outright at
`verticals.len() < 2`, even though the segment break points encoded
the column boundaries unambiguously.

When the vertical-line count is below the existing threshold, walk
the horizontal-segment x-endpoints and cluster them with the same
snap_edges path used for vertical-line columns. Accept the derived
edges only when ≥3 distinct x-positions each appear on ≥50% of the
unique horizontal-line rows — that consistency guard distinguishes
real per-cell segments from decorative rules with varying widths
(which never share endpoints across many rows).

When columns come from segment endpoints, skip the downstream
"spanning_v / partial_v" gate (there are no vertical lines to
validate against). All other gates — horizontal-span coverage,
content density, capture ratio, multi-column distribution, the
uniform-spacing chart-grid rejector — still apply.

Verified on a 7-row × 3-col archival catalog page that previously
extracted 98 chars (a 2-row fragment via the heuristic fallback); now
extracts 1754 chars with all rows + multi-line cells.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:21:09 -04:00
Abimael MartellandClaude Opus 4.7 b5b91470db extract_tables: try vector-grid detectors before text heuristic (#85)
* extract_tables: try vector-grid detectors before text heuristic

extract_tables_in_regions_mem previously ran only the text-only
heuristic detector (tables::detect_tables) on the items inside each
region, discarding the rects and lines that extract_page_text_items
returned. That left the rect-backed and line-backed detectors
(detect_tables_from_rects, detect_tables_from_lines) unused by the
public region-scoped extraction path — they only ran through
detect_vector_grid_in_region_mem, which most callers don't use.

Keep the rects and lines, filter them to each region, and try in
order: rect detector → line detector → heuristic. Each candidate's
markdown is quality-gated by the existing needs_ocr checks
(is_garbage_text, is_cid_garbage, detect_encoding_issues,
looks_like_partial_table_ex); only the first clean output wins.
If all three produce empty or noisy output we still return
needs_ocr=true, matching prior behavior.

Effect on real prod-shape inputs from shadow logs:

  Full-page ruled ledger, 6 cols x ~15 rows:
    before: heuristic emits a 355-char two-row fragment
    after:  line detector emits the full 6520-char table

  Multi-row key/value layout with paragraph values:
    before: heuristic emits a 188-char header-only fragment
    after:  rect detector emits the full 1733-char table including
            the multi-bullet description cell

Existing fixtures that already passed via the heuristic continue to
pass: the quality gate rejects partial vector-grid output and falls
through, so the heuristic still wins where it produced the cleaner
result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Bump version from 1.8.9 to 1.8.10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:25:08 -04:00
Abimael Martell f26efe6673 Bump version from 1.8.8 to 1.8.9 2026-05-12 18:40:12 -04:00
Abimael MartellandClaude Opus 4.7 06ccaf5732 detect_rects: accept multi-row tables with long-content cells (#84)
Three rejection sites in detect_rects.rs killed any candidate grid
where a single cell exceeded 500 chars:

  - detect_row_stripe_table (line 1399)
  - detect_row_stripe_table_from_cell_rects (line 1729)
  - detect_merged_cluster_table (line 2154)

The intent was to skip layout-background rects — sidebars, banners,
section bands — where one big rectangle wraps a paragraph of prose.
Those almost always present as ≤3 row stripes (header / body / footer
or single big block).

Multi-row key/value tables with paragraph-length values in one column
present the same cell-length signal but are legitimate tables.
Gating the rejection on `non_empty_rows < 4` preserves the
layout-background guard for narrow stripe layouts while letting
through multi-row tables with descriptive content.

Verified against a 9-row × 2-column key/value layout where the value
column has multi-line content (~1.4KB in the longest cell). Before:
rejected with `max cell length 1384 > 500`. After: accepted with 89%
density. The existing `test_row_stripe_rejects_layout_background_long_cells`
regression test for narrow stripe layouts still passes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:35:56 -04:00
Abimael MartellandClaude Opus 4.7 5a84c29ea4 detect_lines: accept full-page tables with internal grid (#83)
The page-spanning-frame guard rejected any line set whose bounding box
exceeded ~90% of a standard A4/Letter page in both axes. The intent was
to skip decorative outer borders, but it also threw away every real
full-page table — common in governmental ledgers, financial filings,
and dense report layouts.

Decorative borders have just 4 edges (top/bottom/left/right). Real
full-page tables have many internal row and column rules. Gate the
rejection on `horizontals.len() <= 4 && verticals.len() <= 4` so the
guard still catches bare frames but lets through line sets with real
internal grid structure.

Tested against a regione.lazio.it Estrazione-provvedimenti page (full
A4-width table, 14 rows × 6 cols): now ACCEPTED with 211/211 items
captured. Bare-frame regression test added.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:28:08 -04:00
Abimael MartellandClaude Opus 4.7 96c0f2102a tables/detect_rects: prefer rect-grid edges over text-cluster on N≥3 column tables (#82)
When a wire-bordered table has headers centered/right-aligned in their
cells but data left-aligned, cluster_x_positions can both merge adjacent
data columns (when the data-to-data gap is below the clamped threshold)
and drop the header-only x-positions in its singleton-filter pass. The
cell-rect fallback then used text-cluster column edges and lost a column
or fragmented neighbor cells.

Prefer rect-derived column edges when the rect grid has 3+ columns and
every rect column holds multiple text items. The all-cols-populated
check protects against decorative or background rects (prose laid out
in a frame, cell-fill rects with extra borders) that would otherwise
split a logical column into spurious sub-columns. The existing
prose-in-frame, well-distributed-columns, and wireless-prose guards
still fire for the cases they were built for.

Bump napi version 1.8.7 → 1.8.8.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:44:26 -04:00
Abimael Martell 6086577d11 tables/detect_rects: emit grid for multiline indented cells (#79) 2026-05-07 11:44:53 -07:00
Abimael MartellandCursor f2186ec1aa tables/detect_rects: don't accept relaxed grid on wireless prose (#78)
Require rect-derived column evidence before relaxing prose checks for two-column cell-rect fallbacks, so text-position alignment alone cannot synthesize a vector grid on wireless content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 09:30:29 -07:00
17 changed files with 3580 additions and 159 deletions
+5 -31
View File
@@ -20,17 +20,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
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-
uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --verbose
@@ -61,17 +51,9 @@ jobs:
components: clippy
- name: Cache cargo
uses: actions/cache@v4
uses: Swatinem/rust-cache@v2
with:
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-
key: clippy
- name: Run clippy
run: cargo clippy -- -D warnings
@@ -89,17 +71,9 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
uses: Swatinem/rust-cache@v2
with:
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-
key: build
- name: Build
run: cargo build --release --verbose
+33
View File
@@ -0,0 +1,33 @@
# 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
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.8.6",
"version": "1.9.3",
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
"main": "index.js",
"types": "index.d.ts",
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { detectVectorGridInRegion } = require("./index.js");
const pdfPath =
process.argv[2] ?? "/tmp/pdf_inspector_indent_fixtures/cis_edge_benchmark.pdf";
const pdf = readFileSync(pdfPath);
const dpi = Number(process.argv[3] ?? 200);
const crops = [
{ pageIdx: 29, box: [0, 0, 612, 792], label: "page30-full" },
{ pageIdx: 16, box: [0, 0, 612, 792], label: "page17-full" },
{ pageIdx: 23, box: [0, 0, 612, 792], label: "page24-full" },
];
for (const { pageIdx, box, label } of crops) {
const result = detectVectorGridInRegion(pdf, pageIdx, box, dpi);
if (!result) {
console.log(`${label}: null`);
continue;
}
const rows = result.structureTokens.filter((token) => token === "<tr>").length;
const cols = rows > 0 ? result.cellBboxes.length / rows : 0;
console.log(
`${label}: cells=${result.cellBboxes.length} rows=${rows} cols=${cols}`,
);
}
+24 -2
View File
@@ -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, multiply_matrices};
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
/// Strip PDF comments (% to end of line) from content stream bytes.
///
@@ -664,7 +664,29 @@ pub(crate) fn extract_page_text_items(
if let Some(xobj_type) = xobjects.get(&xobj_name) {
match xobj_type {
XObjectType::Image => {
// Skip images — text extraction only
// 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),
});
}
XObjectType::Form(form_id) => {
// Extract text from Form XObject
+6 -2
View File
@@ -29,8 +29,12 @@ pub(crate) fn detect_columns(
const MIN_ITEMS_PER_COLUMN: usize = 10;
const NOISE_FRACTION: f32 = 0.15;
// Get items for this page
let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect();
// 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();
if page_items.is_empty() {
return vec![];
+63
View File
@@ -227,6 +227,69 @@ 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 |
+37 -13
View File
@@ -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, multiply_matrices};
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
@@ -262,19 +262,43 @@ 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();
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);
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);
}
}
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 => {}
}
}
}
+1483 -54
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -422,7 +422,16 @@ impl Default for MarkdownOptions {
fix_hyphenation: true,
detect_bold: true,
detect_italic: true,
include_images: 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 `![Image: Im0](image)` 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_links: true,
include_page_numbers: false,
strip_headers_footers: true,
+261 -29
View File
@@ -4,11 +4,74 @@
//! gridlines. Many IRS forms and government PDFs use these instead of
//! `re` (rectangle) operators.
use std::collections::HashSet;
use crate::tables::Table;
use crate::types::{PdfLine, TextItem};
use super::detect_rects::{assign_items_to_grid, snap_edges};
/// Derive column edges from the x-endpoints of horizontal-rule
/// segments when no vertical lines were drawn.
///
/// Catalog and archival-finding-aid tables are commonly drawn with
/// per-row horizontal rules broken into N segments (one segment per
/// cell), with no vertical dividers at all. The segment break points
/// (e.g. `[50, 127], [127, 485], [485, 562]` per row) implicitly
/// encode the column boundaries.
///
/// Returns column edges if ≥3 distinct x-positions each show up as a
/// segment endpoint on ≥50% of the unique horizontal-line rows.
/// Returns `None` otherwise — decorative rules with varying widths
/// shouldn't be mistaken for a table.
fn derive_columns_from_horizontal_segments(horizontals: &[(f32, f32, f32)]) -> Option<Vec<f32>> {
if horizontals.len() < 3 {
return None;
}
let mut endpoints: Vec<f32> = Vec::with_capacity(horizontals.len() * 2);
for &(_, x_min, x_max) in horizontals {
endpoints.push(x_min);
endpoints.push(x_max);
}
let clusters = snap_edges(&endpoints, 5.0);
if clusters.len() < 3 {
return None;
}
// Bucket y-values to count unique rows. Tolerance ~0.1pt (×10
// rounding) tolerates the snap_edges 3pt clustering used later
// for row edges.
let unique_rows: HashSet<i32> = horizontals
.iter()
.map(|&(y, _, _)| (y * 10.0).round() as i32)
.collect();
if unique_rows.len() < 2 {
return None;
}
let min_rows = (unique_rows.len() as f32 * 0.5).ceil() as usize;
let qualifying: Vec<f32> = clusters
.iter()
.copied()
.filter(|&cluster_x| {
let rows_touched: HashSet<i32> = horizontals
.iter()
.filter(|&&(_, x_min, x_max)| {
(x_min - cluster_x).abs() < 5.0 || (x_max - cluster_x).abs() < 5.0
})
.map(|&(y, _, _)| (y * 10.0).round() as i32)
.collect();
rows_touched.len() >= min_rows
})
.collect();
if qualifying.len() < 3 {
return None;
}
Some(qualifying)
}
/// Detect tables from line segments on a given page.
///
/// Lines are classified as horizontal or vertical, snapped into grid edges,
@@ -52,25 +115,50 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
// Diagonal lines are ignored
}
if horizontals.len() < 3 || verticals.len() < 2 {
if horizontals.len() < 3 {
return Vec::new();
}
// If no/very-few vertical lines are drawn, try to derive column edges
// from the x-endpoints of the horizontal-rule segments. Catalog and
// archival-finding-aid layouts commonly draw each row's horizontal
// rule as N segments (one per cell), with no vertical dividers at
// all — the segment break points encode the column boundaries.
let implicit_col_edges: Option<Vec<f32>> = if verticals.len() < 2 {
derive_columns_from_horizontal_segments(&horizontals)
} else {
None
};
if verticals.len() < 2 && implicit_col_edges.is_none() {
return Vec::new();
}
let cols_from_segments = implicit_col_edges.is_some();
log::debug!(
"detect_lines p{}: {} horiz, {} vert lines (of {} total on page)",
"detect_lines p{}: {} horiz, {} vert lines (of {} total on page){}",
page,
horizontals.len(),
verticals.len(),
page_lines.len()
page_lines.len(),
if cols_from_segments {
" — columns from horizontal segments"
} else {
""
}
);
// Snap Y-values of horizontal lines → row edges
let h_ys: Vec<f32> = horizontals.iter().map(|(y, _, _)| *y).collect();
let row_edges = snap_edges(&h_ys, 3.0);
// Snap X-values of vertical lines → column edges
let v_xs: Vec<f32> = verticals.iter().map(|(x, _, _)| *x).collect();
let col_edges = snap_edges(&v_xs, 3.0);
// Column edges from drawn verticals when present, else from the
// horizontal-segment endpoints derived above.
let col_edges = if let Some(c) = implicit_col_edges {
c
} else {
let v_xs: Vec<f32> = verticals.iter().map(|(x, _, _)| *x).collect();
snap_edges(&v_xs, 3.0)
};
log::debug!(
"detect_lines p{}: {} row edges, {} col edges after snap",
@@ -110,15 +198,21 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
return Vec::new();
}
// Reject page-spanning frames: if the grid covers >90% of a standard page
// dimension in both axes, it's a border frame, not a table.
// Reject page-spanning frames: a decorative outer border has just 4
// edges (top/bottom/left/right). Real full-page tables — common in
// governmental ledgers, financial reports, etc. — span the same A4 /
// Letter dimensions but have many internal row/column rules. Only
// reject when the line set looks like a bare frame, not a grid.
// Standard pages are ~595×842 (A4) or ~612×792 (Letter).
if table_width > 500.0 && table_height > 700.0 {
if table_width > 500.0 && table_height > 700.0 && horizontals.len() <= 4 && verticals.len() <= 4
{
log::debug!(
"detect_lines p{}: rejected — page-spanning frame ({:.0}×{:.0})",
"detect_lines p{}: rejected — page-spanning frame ({:.0}×{:.0}, {} h + {} v)",
page,
table_width,
table_height
table_height,
horizontals.len(),
verticals.len()
);
return Vec::new();
}
@@ -146,24 +240,33 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
// Validate vertical lines: at least 2 should span a meaningful height.
// Full spanning (>30%) is ideal, but accept many shorter lines (>10%)
// for tables with partial column separators.
let spanning_v = verticals
.iter()
.filter(|(_, y_min, y_max)| (y_max - y_min) > table_height * 0.3)
.count();
let partial_v = verticals
.iter()
.filter(|(_, y_min, y_max)| (y_max - y_min) > table_height * 0.10)
.count();
if spanning_v < 2 && partial_v < 4 {
log::debug!(
"detect_lines p{}: rejected — {} spanning + {} partial V lines",
page,
spanning_v,
partial_v
);
return Vec::new();
}
// for tables with partial column separators. Skipped entirely when
// columns came from horizontal-segment endpoints — there are no
// vertical lines to validate against, and the segment-endpoint
// consistency check in `derive_columns_from_horizontal_segments`
// is the equivalent guard.
let spanning_v = if cols_from_segments {
0
} else {
let s = verticals
.iter()
.filter(|(_, y_min, y_max)| (y_max - y_min) > table_height * 0.3)
.count();
let p = verticals
.iter()
.filter(|(_, y_min, y_max)| (y_max - y_min) > table_height * 0.10)
.count();
if s < 2 && p < 4 {
log::debug!(
"detect_lines p{}: rejected — {} spanning + {} partial V lines",
page,
s,
p
);
return Vec::new();
}
s
};
// Row edges need to be in descending order (top of page = higher Y first)
let mut row_edges_desc = row_edges;
@@ -410,6 +513,135 @@ mod tests {
assert!(tables.is_empty());
}
#[test]
fn test_horizontal_segments_only_implicit_columns_accepted() {
// Catalog/finding-aid pattern: each row's horizontal rule is
// drawn as 3 segments at consistent x-endpoints (50, 127, 485,
// 562), with no vertical lines anywhere. The segment break
// points must be inferred as column edges.
let mut lines = Vec::new();
// Slightly uneven row spacing so the chart-gridline rejector
// (CV < 0.02) doesn't fire.
let row_ys = [80.0_f32, 145.0, 215.0, 280.0, 350.0, 415.0, 485.0];
for &y in &row_ys {
lines.push(make_hline(y, 50.0, 127.0, 1));
lines.push(make_hline(y, 127.0, 485.0, 1));
lines.push(make_hline(y, 485.0, 562.0, 1));
}
// Populate every cell so capture / density checks pass.
let mut items = Vec::new();
for w in row_ys.windows(2) {
let row_y = (w[0] + w[1]) / 2.0;
items.push(make_item("id", 80.0, row_y, 1));
items.push(make_item("description here", 200.0, row_y, 1));
items.push(make_item("date", 510.0, row_y, 1));
}
let tables = detect_tables_from_lines(&items, &lines, 1);
assert_eq!(
tables.len(),
1,
"horizontal-segment-only grid should be accepted"
);
let t = &tables[0];
assert!(
t.cells.len() >= 4,
"expected ≥4 rows, got {}",
t.cells.len()
);
assert_eq!(t.cells[0].len(), 3, "expected 3 columns");
}
#[test]
fn test_horizontal_segments_with_inconsistent_endpoints_rejected() {
// Decorative rules of varying widths shouldn't be detected as a
// table — each line has its own x-endpoints, no consistent
// column boundary survives the 50%-of-rows threshold.
let lines = vec![
make_hline(100.0, 50.0, 150.0, 1),
make_hline(200.0, 50.0, 220.0, 1),
make_hline(300.0, 50.0, 310.0, 1),
make_hline(400.0, 50.0, 470.0, 1),
];
let items = vec![
make_item("decorative", 100.0, 150.0, 1),
make_item("text", 100.0, 250.0, 1),
];
let tables = detect_tables_from_lines(&items, &lines, 1);
assert!(
tables.is_empty(),
"varying-width decorative rules should not be detected"
);
}
#[test]
fn test_page_spanning_bare_frame_rejected() {
// Just an outer A4-sized rectangle: 2 horizontals + 2 verticals.
// No internal structure → decorative border, not a table.
let lines = vec![
make_hline(20.0, 20.0, 575.0, 1), // top
make_hline(820.0, 20.0, 575.0, 1), // bottom
make_vline(20.0, 20.0, 820.0, 1), // left
make_vline(575.0, 20.0, 820.0, 1), // right
];
let items = vec![
make_item("title", 100.0, 100.0, 1),
make_item("body", 100.0, 200.0, 1),
];
let tables = detect_tables_from_lines(&items, &lines, 1);
assert!(
tables.is_empty(),
"Page-sized 4-edge frame should be rejected as decoration"
);
}
#[test]
fn test_page_spanning_grid_with_internal_lines_accepted() {
// Full-page table (governmental-ledger pattern): A4-sized grid
// that previously hit the "page-spanning frame" early reject
// before downstream validation could even look at it.
// Verticals span the full table height so we isolate the
// frame-vs-grid decision under test.
let mut lines = Vec::new();
// 13 horizontal rules: header + 12 row separators
let h_ys = [
22.5, 37.9, 95.5, 144.5, 184.9, 233.9, 291.7, 340.7, 415.8, 499.6, 574.7, 623.7, 698.8,
];
for &y in &h_ys {
lines.push(make_hline(y, 22.6, 566.6, 1));
}
// 7 column dividers spanning full table height.
let v_xs = [22.6, 66.3, 116.3, 186.6, 263.1, 493.5, 566.5];
for &x in &v_xs {
lines.push(make_vline(x, 22.5, 698.8, 1));
}
// Populate every cell so the capture-ratio + density checks pass.
let mut items = Vec::new();
for r in 0..(h_ys.len() - 1) {
let row_y = (h_ys[r] + h_ys[r + 1]) / 2.0;
for c in 0..(v_xs.len() - 1) {
let col_x = (v_xs[c] + v_xs[c + 1]) / 2.0;
items.push(make_item("x", col_x, row_y, 1));
}
}
let tables = detect_tables_from_lines(&items, &lines, 1);
assert_eq!(
tables.len(),
1,
"Full-page table with internal grid should be accepted"
);
let t = &tables[0];
assert!(
t.cells.len() >= 6,
"expected ≥6 rows, got {}",
t.cells.len()
);
assert!(
t.cells[0].len() >= 3,
"expected ≥3 columns, got {}",
t.cells[0].len()
);
}
#[test]
fn test_single_column_rejected() {
// Only 2 col edges (1 column) — not a table even with verticals
+430 -16
View File
@@ -231,6 +231,15 @@ 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 {
@@ -1394,13 +1403,15 @@ fn detect_row_stripe_table(
.max()
.unwrap_or(0);
// Allow longer cells for multi-column tables (descriptions in one column
// are common). Single-column or 2-column "tables" with giant cells are
// almost always layout backgrounds.
// are common). Narrow grids with giant cells are usually layout
// backgrounds — but only when the row count is also small. A 4+-row
// key/value table with one descriptive column reads as a real table
// on every other gate, so don't reject it on cell length alone.
let max_allowed = if num_cols >= 3 { 2000 } else { 500 };
if max_cell_len > max_allowed {
if max_cell_len > max_allowed && non_empty_rows < 4 {
debug!(
" row-stripe rejected: max cell length {} > {} (layout background)",
max_cell_len, max_allowed
" row-stripe rejected: max cell length {} > {} (layout background, {} rows)",
max_cell_len, max_allowed, non_empty_rows
);
return None;
}
@@ -1632,7 +1643,49 @@ fn detect_row_stripe_table_from_cell_rects(
}
};
// For wired-grid tables whose header text is centered/right-aligned but
// whose data is left-aligned, cluster_x_positions can drop the header-only
// x-cluster in its singleton-filter pass and merge adjacent data clusters
// when the gap is below threshold, losing a column. Rect borders are
// ground truth in that case — but only when each rect column actually
// holds text. Decorative or background rects (prose laid out in a frame,
// cell-fill rects with extra borders) can produce more rect-derived
// columns than the text supports; preferring rects there would split a
// logical column into spurious sub-columns.
let rect_cols_match_text = match (&rect_col_edges, &text_col_edges) {
(Some(rect_edges), _) if rect_edges.len() >= 4 => {
let num_rect_cols = rect_edges.len() - 1;
let mut col_item_counts = vec![0usize; num_rect_cols];
for (_, item) in &page_items {
let cx = item.x + item.width / 2.0;
for c in 0..num_rect_cols {
if cx >= rect_edges[c] - 2.0 && cx <= rect_edges[c + 1] + 2.0 {
col_item_counts[c] += 1;
break;
}
}
}
// Require every rect column to hold multiple text items. A rect
// column with no (or only one) item is decorative or the rect grid
// is detecting a spurious column the data does not need; in those
// cases the old text-cluster preference is the safer fallback.
col_item_counts.iter().all(|&n| n >= 2)
}
_ => false,
};
let (col_edges, columns_from_text) = match (rect_col_edges, text_col_edges) {
(Some(rect_edges), text_edges_opt) if rect_cols_match_text => {
debug!(
" cell-rect using {} rect-derived columns (text clusters: {}; rect cols well-distributed)",
rect_edges.len() - 1,
text_edges_opt
.as_ref()
.map(|e| (e.len() - 1) as i32)
.unwrap_or(-1)
);
(rect_edges, false)
}
(Some(rect_edges), Some(text_edges)) if rect_edges.len() <= text_edges.len() => {
debug!(
" cell-rect using {} rect-derived columns over {} text clusters",
@@ -1667,12 +1720,25 @@ fn detect_row_stripe_table_from_cell_rects(
page_items.len()
);
let (cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
let (mut cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
if item_indices.is_empty() {
return None;
}
let mut row_edges = row_edges;
let (collapsed_cells, collapsed_row_edges, collapsed_rows) =
collapse_multiline_description_rows(cells, row_edges, &col_edges);
let has_wrapped_description_rows = collapsed_rows > 0;
cells = collapsed_cells;
row_edges = collapsed_row_edges;
if collapsed_rows > 0 {
debug!(
" cell-rect collapsed {} wrapped description rows",
collapsed_rows
);
}
// Validate: >=2 non-empty rows, >=25% density
let non_empty_rows = cells
.iter()
@@ -1686,6 +1752,7 @@ fn detect_row_stripe_table_from_cell_rects(
return None;
}
let num_rows = cells.len();
let total_cells = (num_cols * num_rows) as f32;
let non_empty_cells = cells
.iter()
@@ -1705,17 +1772,21 @@ fn detect_row_stripe_table_from_cell_rects(
return None;
}
// Reject tables with paragraph-length cells (layout backgrounds, not tables)
// Reject tables with paragraph-length cells — typically layout
// backgrounds (sidebars, banners) where a single big rectangle
// contains a wall of prose. Spare multi-row key/value tables where
// the value column is a multi-bullet description: those pass every
// other gate and shouldn't get killed on cell length alone.
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
if max_cell_len > 500 {
if max_cell_len > 500 && non_empty_rows < 4 {
debug!(
" cell-rect rejected: max cell length {} > 500",
max_cell_len
" cell-rect rejected: max cell length {} > 500 ({} rows, layout background)",
max_cell_len, non_empty_rows
);
return None;
}
@@ -1796,12 +1867,17 @@ fn detect_row_stripe_table_from_cell_rects(
// discriminator.
const PROSE_MEAN_CHAR_THRESHOLD: usize = 65;
let mean_chars = total_chars / counted;
if mean_chars > PROSE_MEAN_CHAR_THRESHOLD {
if mean_chars > PROSE_MEAN_CHAR_THRESHOLD && !has_wrapped_description_rows {
debug!(
" cell-rect rejected: prose-in-frame, mean non-empty cell {} chars > {} (prose words {}/{})",
mean_chars, PROSE_MEAN_CHAR_THRESHOLD, prose_cells, counted
);
return None;
} else if mean_chars > PROSE_MEAN_CHAR_THRESHOLD {
debug!(
" cell-rect prose check relaxed: wrapped description rows, mean {} chars (prose words {}/{})",
mean_chars, prose_cells, counted
);
}
// (b) Two text-derived columns are not enough vector evidence once
@@ -1863,6 +1939,132 @@ fn detect_row_stripe_table_from_cell_rects(
Some(Table::new(column_centers, row_centers, cells, item_indices))
}
/// Merge wrapped description-line bands back into their visual data rows.
///
/// Some Word/PDF exports draw enough rectangle geometry to prove a table exists
/// but expose Y bands per wrapped text line instead of per cell row. In the
/// common mapping-table shape, a narrow row-label column precedes one wide
/// description column, and wrapped continuation bands have content only in that
/// wide column. Merge only that high-confidence shape so framed prose still
/// falls through the existing prose guards.
fn collapse_multiline_description_rows(
cells: Vec<Vec<String>>,
row_edges: Vec<f32>,
col_edges: &[f32],
) -> (Vec<Vec<String>>, Vec<f32>, usize) {
let num_rows = cells.len();
let num_cols = col_edges.len().saturating_sub(1);
if num_rows < 3 || num_cols < 3 || row_edges.len() != num_rows + 1 {
return (cells, row_edges, 0);
}
let table_width = col_edges[num_cols] - col_edges[0];
if table_width <= 0.0 {
return (cells, row_edges, 0);
}
let Some((description_col, description_width)) = (0..num_cols)
.map(|c| (c, col_edges[c + 1] - col_edges[c]))
.max_by(|a, b| a.1.total_cmp(&b.1))
else {
return (cells, row_edges, 0);
};
// Require a preceding row-label column. Without it (e.g. a prose frame
// split into text-start columns), "one populated wide column" is not enough
// evidence to find visual row starts safely.
if description_col == 0 || description_width < table_width * 0.35 {
return (cells, row_edges, 0);
}
let row_has_left_label = |row: &[String]| {
row.iter()
.take(description_col)
.any(|cell| !cell.trim().is_empty())
};
let labeled_rows = cells.iter().filter(|row| row_has_left_label(row)).count();
if labeled_rows < 2 {
return (cells, row_edges, 0);
}
let mut merged_rows = 0usize;
let mut wrapped_description_rows = 0usize;
let mut new_cells: Vec<Vec<String>> = Vec::with_capacity(num_rows);
let mut new_edges = Vec::with_capacity(row_edges.len());
new_edges.push(row_edges[0]);
for (row_idx, row) in cells.into_iter().enumerate() {
let desc_text = row
.get(description_col)
.map(String::as_str)
.unwrap_or("")
.trim();
let left_label = row_has_left_label(&row);
let non_desc_non_empty = row
.iter()
.enumerate()
.filter(|(col, cell)| *col != description_col && !cell.trim().is_empty())
.count();
// Wrapped continuation bands contain only description-column text.
// The preceding label/marker column is empty because the visual row's
// label cell spans the whole wrapped block.
let is_description_continuation = row_idx > 0
&& !desc_text.is_empty()
&& !left_label
&& non_desc_non_empty == 0
&& !new_cells.is_empty();
// Header cells are often split as "Controls" / "Version" in the first
// column while the other header labels sit on the first band.
let only_first_col = row
.iter()
.enumerate()
.all(|(col, cell)| col == 0 || cell.trim().is_empty());
let is_header_continuation = row_idx > 0
&& only_first_col
&& row
.first()
.is_some_and(|cell| !cell.trim().is_empty() && cell.chars().count() <= 24)
&& !new_cells.is_empty()
&& new_cells
.last()
.is_some_and(|prev| prev.iter().filter(|c| !c.trim().is_empty()).count() >= 2);
if is_description_continuation || is_header_continuation {
if let Some(prev) = new_cells.last_mut() {
for (col, cell) in row.iter().enumerate() {
let text = cell.trim();
if text.is_empty() {
continue;
}
if !prev[col].trim().is_empty() {
prev[col].push(' ');
}
prev[col].push_str(text);
}
}
merged_rows += 1;
if is_description_continuation {
wrapped_description_rows += 1;
}
} else {
if !new_cells.is_empty() {
new_edges.push(row_edges[row_idx]);
}
new_cells.push(row);
}
}
new_edges.push(*row_edges.last().unwrap());
if merged_rows == 0 || new_cells.len() < 2 || new_edges.len() != new_cells.len() + 1 {
return (new_cells, row_edges, 0);
}
(new_cells, new_edges, wrapped_description_rows)
}
/// Detect a table by merging all cluster rects into one group.
///
/// This handles clip-path PDFs where each column's cell rects form a separate
@@ -1998,18 +2200,20 @@ fn detect_merged_cluster_table(
return None;
}
// Reject if any cell has excessive text — layout background rects produce
// "cells" containing paragraphs, not short data-table values.
// Reject if any cell has excessive text — layout background rects
// produce "cells" containing paragraphs, not short data-table values.
// Multi-row key/value tables can legitimately have one column of
// long descriptive text, so only reject narrow-row layouts here.
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
if max_cell_len > 500 {
if max_cell_len > 500 && non_empty_rows < 4 {
debug!(
" merged-cluster rejected: max cell length {} > 500 (layout background)",
max_cell_len
" merged-cluster rejected: max cell length {} > 500 ({} rows, layout background)",
max_cell_len, non_empty_rows
);
return None;
}
@@ -2439,6 +2643,46 @@ mod tests {
);
}
#[test]
fn test_row_stripe_accepts_multi_row_key_value_long_cells() {
// Multi-row 2-column key/value table where one value cell holds
// a paragraph (>500 chars). The old `max_cell_len > 500` check
// rejected this shape as a "layout background"; with the
// multi-row guard, it should be accepted.
let mut rects = Vec::new();
let row_h = 25.0_f32;
let y_top = 700.0_f32;
for i in 0..8 {
let y = y_top - (i as f32) * row_h;
rects.push((40.0, y, 510.0, row_h));
}
let mut items = Vec::new();
for i in 0..8 {
let row_center_y = y_top - (i as f32) * row_h + row_h / 2.0;
// Left column: short label
items.push(make_item(&format!("Field {}", i), 45.0, row_center_y, 10.0));
// Right column: short value, except the last row which is a paragraph
let value = if i == 7 {
"X".repeat(800)
} else {
"value".to_string()
};
items.push(make_item(&value, 300.0, row_center_y, 10.0));
}
let result = detect_row_stripe_table(&items, &rects, 1);
assert!(
result.is_some(),
"multi-row key/value table with one long cell should be accepted"
);
let t = result.unwrap();
assert!(
t.cells.len() >= 4,
"expected ≥4 rows, got {}",
t.cells.len()
);
assert_eq!(t.cells[0].len(), 2, "expected 2 columns");
}
// --- propagate_merged_cells ---
#[test]
@@ -3109,6 +3353,176 @@ mod tests {
);
}
#[test]
fn multiline_indented_description_rows_collapse_to_visual_rows() {
let page = 1;
let col_edges = [0.0, 60.0, 420.0, 460.0, 500.0, 540.0];
let row_edges = [
340.0, 320.0, 300.0, 270.0, 250.0, 230.0, 200.0, 180.0, 160.0,
];
let mut rects = Vec::new();
for row in 0..row_edges.len() - 1 {
let y_top = row_edges[row];
let y_bot = row_edges[row + 1];
for col in 0..col_edges.len() - 1 {
rects.push((
col_edges[col],
y_bot,
col_edges[col + 1] - col_edges[col],
y_top - y_bot,
));
}
}
let mut items = vec![
make_item("Controls", 8.0, 330.0, 9.0),
make_item("Control", 70.0, 330.0, 9.0),
make_item("IG 1", 428.0, 330.0, 9.0),
make_item("IG 2", 468.0, 330.0, 9.0),
make_item("IG 3", 508.0, 330.0, 9.0),
make_item("Version", 8.0, 310.0, 9.0),
make_item("v8", 20.0, 285.0, 9.0),
make_item(
"4.5 Implement and Manage a Firewall on End-User Devices",
70.0,
285.0,
9.0,
),
make_item("*", 438.0, 285.0, 9.0),
make_item("*", 478.0, 285.0, 9.0),
make_item("*", 518.0, 285.0, 9.0),
make_item("v7", 20.0, 215.0, 9.0),
make_item(
"9.4 Apply Host-based Firewalls or Port-Filtering",
70.0,
215.0,
9.0,
),
make_item("*", 478.0, 215.0, 9.0),
make_item("*", 518.0, 215.0, 9.0),
];
items.push(make_item(
"Implement and manage a host-based firewall or port-filtering tool",
84.0,
260.0,
8.0,
));
items.push(make_item(
"on end-user devices with a default-deny rule",
84.0,
240.0,
8.0,
));
items.push(make_item(
"Apply host-based firewalls or port filtering tools on end systems",
84.0,
190.0,
8.0,
));
items.push(make_item(
"and deny unauthorized network communication",
84.0,
170.0,
8.0,
));
let table = detect_row_stripe_table_from_cell_rects(&items, &rects, page)
.expect("expected multiline description table");
assert_eq!(table.columns.len(), 5);
assert_eq!(
table.rows.len(),
3,
"wrapped lines should collapse to header plus two data rows"
);
assert_eq!(table.cells[0][0], "Controls Version");
assert!(table.cells[1][1].contains("host-based firewall"));
assert!(table.cells[1][1].contains("default-deny rule"));
assert!(table.cells[2][1].contains("deny unauthorized"));
}
/// Wire-bordered 4-column table whose header text is centered/right-aligned
/// inside each cell while the data is left-aligned: cluster_x_positions
/// merges adjacent columns (data Item→EAN gap is below threshold) and
/// drops the header-only x-clusters in the filter pass, leaving only 3
/// text-derived columns. Rect borders are 4 columns of ground truth.
/// Before the fix the cell-rect path preferred text edges when they were
/// the smaller set — losing a column. After the fix, 3+ rect columns
/// always win.
#[test]
fn wired_header_data_misaligned_keeps_all_columns_from_rects() {
let page = 1;
// 4 cols: Item | EAN | Nombre | Cant
let col_xs = [380.0_f32, 410.0, 470.0, 660.0, 700.0];
// Header + 9 data rows at 15pt tall each (y descending).
let row_ys: Vec<f32> = (0..=10).map(|r| 400.0 - 15.0 * r as f32).collect();
let mut rects: Vec<(f32, f32, f32, f32)> = Vec::new();
for r in 0..10 {
let y_top = row_ys[r];
let y_bot = row_ys[r + 1];
for c in 0..4 {
rects.push((col_xs[c], y_bot, col_xs[c + 1] - col_xs[c], y_top - y_bot));
}
}
let mut items: Vec<TextItem> = Vec::new();
// Header row (y ≈ 392.5): headers sit further to the right than data
// because they are centered/right-aligned in the cells.
items.push(make_item("Item", 389.0, 392.5, 9.0));
items.push(make_item("EAN", 432.0, 392.5, 9.0));
items.push(make_item("Nombre", 552.0, 392.5, 9.0));
items.push(make_item("Cant", 672.0, 392.5, 9.0));
let names = [
"Arnes Frontal",
"Arnes Motor",
"Arnes Piso",
"Arnes Techo",
"Arnes Puerta",
"Arnes Tablero",
"Arnes Trasero",
"Arnes Lateral",
"Arnes Sensor",
];
for r in 0..9 {
let y = 377.5 - 15.0 * r as f32;
items.push(make_item(&(r + 1).to_string(), 396.0, y, 9.0));
items.push(make_item("7701023403016", 410.0, y, 9.0));
items.push(make_item(names[r], 480.0, y, 9.0));
items.push(make_item("1", 680.0, y, 9.0));
}
let table = detect_row_stripe_table_from_cell_rects(&items, &rects, page)
.expect("wired 4-column table with header/data x-misalignment must detect");
assert_eq!(
table.columns.len(),
4,
"expected 4 columns from rect borders; cells: {:?}",
table.cells
);
for c in 0..4 {
let any_populated = table.cells.iter().any(|row| !row[c].trim().is_empty());
assert!(
any_populated,
"column {} empty across all rows; cells: {:?}",
c, table.cells
);
}
// Header row populated in all 4 cells.
let header = &table.cells[0];
assert_eq!(header[0].trim(), "Item");
assert_eq!(header[1].trim(), "EAN");
assert_eq!(header[2].trim(), "Nombre");
assert_eq!(header[3].trim(), "Cant");
// First data row: Item="1", EAN, name, count="1" — no Item↔EAN merge.
let data1 = &table.cells[1];
assert_eq!(data1[0].trim(), "1");
assert_eq!(data1[1].trim(), "7701023403016");
assert!(data1[2].trim().contains("Arnes"));
assert_eq!(data1[3].trim(), "1");
}
#[test]
fn failed_cluster_no_hint_without_items() {
// Rects with no text items inside → no failed-cluster hint generated.
+269 -7
View File
@@ -160,6 +160,81 @@ 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();
@@ -185,6 +260,9 @@ 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.
@@ -222,31 +300,76 @@ 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.
@@ -257,11 +380,18 @@ 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
&& (prev_filled > filled_cells
|| (continues_wrapped_first_column_label && 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;
@@ -407,6 +537,44 @@ 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![
@@ -459,6 +627,100 @@ 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![
+715
View File
@@ -460,6 +460,7 @@ 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();
@@ -547,6 +548,536 @@ 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).
@@ -689,6 +1220,190 @@ 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![
Binary file not shown.
Binary file not shown.
+214 -3
View File
@@ -2,13 +2,14 @@
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, process_pdf_mem,
process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType,
TextItem,
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,
};
use std::collections::HashSet;
@@ -1648,6 +1649,33 @@ fn test_bits_pilani_page8_table_detection() {
assert!(!region.needs_ocr, "Page 8 table should still be detected");
}
#[test]
fn test_extract_tables_in_regions_uses_line_grid() {
// Stroked-grid table (m/l/S path operators forming a 2x2 grid).
// The heuristic text-only detector handles the same cells already,
// so this guards that the line-backed path doesn't regress: the
// markdown still contains all four data cells.
let buf = synthetic_vector_grid_pdf(false);
let results =
extract_tables_in_regions_mem(&buf, &[(0, vec![[40.0, 50.0, 220.0, 760.0]])]).unwrap();
let region = &results[0].regions[0];
assert!(
!region.needs_ocr,
"stroked-grid table should be extracted, got needs_ocr=true"
);
for tok in ["A1", "B1", "A2", "B2"] {
assert!(
region.text.contains(tok),
"expected '{tok}' in output, got: {}",
region.text
);
}
assert!(
region.text.contains('|'),
"expected pipe-delimited markdown"
);
}
// =========================================================================
// extract_tables_with_structure_mem tests (TSR-aware path)
// =========================================================================
@@ -3364,3 +3392,186 @@ 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);
}