Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b0ca64ffb |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
edition = "2021"
|
||||
autobins = false
|
||||
authors = ["Firecrawl Team"]
|
||||
|
||||
Generated
+2
-2
@@ -851,7 +851,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"include_dir",
|
||||
@@ -867,7 +867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector-napi"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector-napi"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
+6
-6
@@ -8,12 +8,12 @@
|
||||
"@napi-rs/cli": "^3.4.1",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@firecrawl/pdf-inspector",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
@@ -52,11 +52,11 @@
|
||||
"@napi-rs/cli": "^3.4.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.1",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.1"
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ build-backend = "maturin"
|
||||
name = "pdf-inspector"
|
||||
# Keep package versions in sync with `python3 scripts/version.py <version>`.
|
||||
# CI publishes automatically when the synchronized change lands on main.
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
||||
readme = "docs/python.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
+1
-1
@@ -975,7 +975,7 @@ result = pdf_inspector.<span class="fn">process_pdf</span>(<span class="str">"do
|
||||
<script>
|
||||
(() => {
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.14.1/pdf_inspector_wasm.js";
|
||||
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.14.0/pdf_inspector_wasm.js";
|
||||
const input = document.querySelector("#pdf-input");
|
||||
const dropZone = document.querySelector("#drop-zone");
|
||||
const filePanel = document.querySelector("#demo-file");
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
//! Bounded content-stream decoding.
|
||||
//!
|
||||
//! `lopdf::content::Content::decode` materializes every operator before any
|
||||
//! caller can apply a limit. A compact page of `q Q` pairs can therefore
|
||||
//! allocate hundreds of megabytes and abort. Count operators first (without
|
||||
//! allocating `Operation` objects) and skip decode when the cap is exceeded.
|
||||
|
||||
use crate::PdfError;
|
||||
use lopdf::content::Content;
|
||||
|
||||
/// Maximum content-stream operators decoded for a page or a single Form
|
||||
/// XObject. Matches the previous post-decode skip threshold.
|
||||
pub(crate) const MAX_PAGE_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Decode `data` unless it contains more than `max_operations` operators.
|
||||
///
|
||||
/// Returns `Ok(None)` when the stream exceeds the cap, so callers can skip
|
||||
/// extraction without first allocating the operation vector.
|
||||
pub(crate) fn decode_content_bounded(
|
||||
data: &[u8],
|
||||
max_operations: usize,
|
||||
) -> Result<Option<Content>, PdfError> {
|
||||
if content_exceeds_operation_limit(data, max_operations) {
|
||||
return Ok(None);
|
||||
}
|
||||
Content::decode(data)
|
||||
.map(Some)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn content_exceeds_operation_limit(data: &[u8], max_operations: usize) -> bool {
|
||||
count_content_operators(data, max_operations.saturating_add(1)) > max_operations
|
||||
}
|
||||
|
||||
/// Count operators using the same token rules as lopdf's content parser,
|
||||
/// stopping at `limit`. Does not allocate `Operation` / `Object` values.
|
||||
fn count_content_operators(data: &[u8], limit: usize) -> usize {
|
||||
let mut i = 0;
|
||||
let mut count = 0;
|
||||
while i < data.len() && count < limit {
|
||||
skip_content_space(data, &mut i);
|
||||
if i >= data.len() {
|
||||
break;
|
||||
}
|
||||
if data[i] == b'%' {
|
||||
skip_comment(data, &mut i);
|
||||
continue;
|
||||
}
|
||||
match data[i] {
|
||||
b'(' => i = skip_literal_string(data, i),
|
||||
b'<' => {
|
||||
if data.get(i + 1) == Some(&b'<') {
|
||||
i += 2;
|
||||
} else {
|
||||
i = skip_hex_string(data, i);
|
||||
}
|
||||
}
|
||||
b'>' => {
|
||||
i += 1;
|
||||
if data.get(i) == Some(&b'>') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' | b']' => i += 1,
|
||||
b'/' => skip_name(data, &mut i),
|
||||
b'+' | b'-' | b'.' => skip_number(data, &mut i),
|
||||
b if b.is_ascii_digit() => skip_number(data, &mut i),
|
||||
b if is_operator_byte(b) => {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < data.len() && is_operator_byte(data[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let token = &data[start..i];
|
||||
if token == b"true" || token == b"false" || token == b"null" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if token == b"BI" && (i >= data.len() || is_content_space(data[i])) {
|
||||
i = skip_inline_image_after_bi(data, i);
|
||||
}
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn is_content_space(b: u8) -> bool {
|
||||
// PDF whitespace (ISO 32000): NUL, tab, LF, FF, CR, space. Names must
|
||||
// stop on these so a following operator is not absorbed into `/Name`.
|
||||
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
|
||||
}
|
||||
|
||||
fn is_operator_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphabetic() || matches!(b, b'*' | b'\'' | b'"')
|
||||
}
|
||||
|
||||
fn is_delimiter(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
|
||||
)
|
||||
}
|
||||
|
||||
fn skip_content_space(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && is_content_space(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_comment(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && data[*i] != b'\n' && data[*i] != b'\r' {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_literal_string(data: &[u8], mut i: usize) -> usize {
|
||||
let mut depth = 1i32;
|
||||
i += 1;
|
||||
while i < data.len() && depth > 0 {
|
||||
match data[i] {
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_hex_string(data: &[u8], mut i: usize) -> usize {
|
||||
i += 1;
|
||||
while i < data.len() && data[i] != b'>' {
|
||||
i += 1;
|
||||
}
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_name(data: &[u8], i: &mut usize) {
|
||||
*i += 1;
|
||||
while *i < data.len() && !is_content_space(data[*i]) && !is_delimiter(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_number(data: &[u8], i: &mut usize) {
|
||||
if *i < data.len() && matches!(data[*i], b'+' | b'-') {
|
||||
*i += 1;
|
||||
}
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
if *i < data.len() && data[*i] == b'.' {
|
||||
*i += 1;
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a `BI` operator, skip inline-image data through `EI`.
|
||||
/// Uses the same PDF whitespace set as `is_content_space`. If `EI` is not
|
||||
/// found, leave the cursor in place so later operators are still counted
|
||||
/// (undercounting would let decode allocate the full vector).
|
||||
fn skip_inline_image_after_bi(data: &[u8], mut i: usize) -> usize {
|
||||
skip_content_space(data, &mut i);
|
||||
let rest = &data[i..];
|
||||
if let Some(pos) = rest.windows(4).position(|w| {
|
||||
is_content_space(w[0]) && w[1] == b'E' && w[2] == b'I' && is_content_space(w[3])
|
||||
}) {
|
||||
return i + pos + 3;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lopdf_op_count(data: &[u8]) -> usize {
|
||||
Content::decode(data)
|
||||
.map(|c| c.operations.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// DoS safety: never report fewer operators than lopdf would allocate.
|
||||
/// Overcount is acceptable (skip a page); undercount would re-open decode.
|
||||
fn assert_count_does_not_undercount(data: &[u8]) {
|
||||
let ours = count_content_operators(data, usize::MAX);
|
||||
match Content::decode(data) {
|
||||
Ok(content) => assert!(
|
||||
ours >= content.operations.len(),
|
||||
"undercount: ours={ours} lopdf={} for {:?}",
|
||||
content.operations.len(),
|
||||
String::from_utf8_lossy(data)
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_count_matches_lopdf_for_typical_streams() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b"q 1 0 0 1 0 0 cm BT /F1 12 Tf 72 720 Td (Hello) Tj ET Q",
|
||||
b"q Q q Q",
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 512 Tm (first) Tj (struck) ' ET",
|
||||
b"1 0 0 rg 0 0 10 10 re f",
|
||||
b"true false null q",
|
||||
b"% comment\nq Q\n",
|
||||
b"[ (a) 1 (b) ] TJ",
|
||||
b"1 0 0 1 0 0 cm /Im0 Do",
|
||||
];
|
||||
for data in samples {
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data),
|
||||
"count mismatch for {}",
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_comments_are_not_operators() {
|
||||
let data = b"(q Q Tj) Tj % q Q\nET";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // Tj, ET
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_counts_as_one_operator() {
|
||||
let data = b"BI /W 2 /H 2 /CS /RGB /BPC 8 ID \x00\x01\x02\x03 EI q";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // BI, q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_ei_accepts_pdf_whitespace() {
|
||||
let tab = b"BI /W 1 /H 1 ID \xff\tEI\t q Q";
|
||||
let nul = b"BI /W 1 /H 1 ID \xff\x00EI\x00 q Q";
|
||||
let ff = b"BI /W 1 /H 1 ID \xff\x0cEI\x0c q Q";
|
||||
for data in [tab.as_slice(), nul.as_slice(), ff.as_slice()] {
|
||||
assert_count_does_not_undercount(data);
|
||||
assert!(
|
||||
count_content_operators(data, usize::MAX) >= 3,
|
||||
"BI plus following q Q must remain visible after EI, got {} for {:?}",
|
||||
count_content_operators(data, usize::MAX),
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_is_skipped_when_operator_cap_is_exceeded() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..20 {
|
||||
data.extend_from_slice(b"q Q\n");
|
||||
}
|
||||
assert!(decode_content_bounded(&data, 10).unwrap().is_none());
|
||||
let decoded = decode_content_bounded(&data, 50).unwrap().unwrap();
|
||||
assert_eq!(decoded.operations.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_whitespace_does_not_swallow_following_operator() {
|
||||
// NUL / form-feed end a name (PDF whitespace). Absorbing `q` into
|
||||
// `/x` would undercount and let decode allocate the operator vector.
|
||||
let mut nul_sep = Vec::new();
|
||||
let mut ff_sep = Vec::new();
|
||||
for _ in 0..8_000 {
|
||||
nul_sep.extend_from_slice(b"/x\x00q");
|
||||
ff_sep.extend_from_slice(b"/x\x0cq");
|
||||
}
|
||||
assert_count_does_not_undercount(&nul_sep);
|
||||
assert_count_does_not_undercount(&ff_sep);
|
||||
assert!(count_content_operators(&ff_sep, usize::MAX) >= 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_streams_do_not_undercount_vs_lopdf() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b".5 0 0 .5 0 0 cm",
|
||||
b"+1 -2 3.0 rg",
|
||||
b"<0041> Tj",
|
||||
b"(unbalanced",
|
||||
b"BI /W 1 /H 1 ID \xff\xff no EI here q Q q Q",
|
||||
b"q\x00Q\x00q\x00Q",
|
||||
b"/F1\x0c12 Tf (Hi) Tj",
|
||||
b"{ 1 2 add } cvx",
|
||||
];
|
||||
for data in samples {
|
||||
assert_count_does_not_undercount(data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn million_q_pairs_are_rejected_without_decode() {
|
||||
let mut data = Vec::with_capacity((MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=MAX_PAGE_OPERATIONS {
|
||||
data.extend_from_slice(b"q\n");
|
||||
}
|
||||
assert!(content_exceeds_operation_limit(&data, MAX_PAGE_OPERATIONS));
|
||||
assert!(decode_content_bounded(&data, MAX_PAGE_OPERATIONS)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use super::fonts::{
|
||||
CMapDecisionCache, FontStyleCache,
|
||||
};
|
||||
use super::underline::UnderlineLine;
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, FormWalkBudget, XObjectType};
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, XObjectType};
|
||||
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
|
||||
/// Strip PDF comments (% to end of line) from content stream bytes.
|
||||
@@ -137,11 +137,8 @@ fn rise_adjusted(tm: &[f32; 6], rise: f32) -> [f32; 6] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns `(page_extraction, has_gid_fonts, coords_rotated, skipped_invisible)`
|
||||
/// where `has_gid_fonts` indicates the page uses fonts with unresolvable
|
||||
/// gid-encoded glyphs and `skipped_invisible` reports that invisible (Tr 3)
|
||||
/// text was present but suppressed — callers can use it to decide whether an
|
||||
/// `include_invisible` retry could recover anything at all.
|
||||
/// Returns `(page_extraction, has_gid_fonts)` where `has_gid_fonts` indicates
|
||||
/// the page uses fonts with unresolvable gid-encoded glyphs.
|
||||
pub(crate) fn extract_page_text_items(
|
||||
doc: &Document,
|
||||
page_id: ObjectId,
|
||||
@@ -149,8 +146,9 @@ pub(crate) fn extract_page_text_items(
|
||||
font_cmaps: &FontCMaps,
|
||||
include_invisible: bool,
|
||||
style_cache: &mut FontStyleCache,
|
||||
form_budget: &mut FormWalkBudget,
|
||||
) -> Result<(PageExtraction, bool, bool, bool), PdfError> {
|
||||
) -> Result<(PageExtraction, bool, bool), PdfError> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut rects: Vec<PdfRect> = Vec::new();
|
||||
let mut clip_rects: Vec<PdfRect> = Vec::new();
|
||||
@@ -254,27 +252,22 @@ pub(crate) fn extract_page_text_items(
|
||||
// Content::decode parser, causing it to skip operators like ET and Q.
|
||||
let content_data = strip_pdf_comments(&content_data);
|
||||
|
||||
let content = match super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
)? {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — content stream exceeds {} operations",
|
||||
page_num,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false, false));
|
||||
}
|
||||
};
|
||||
let content = Content::decode(&content_data).map_err(|e| PdfError::Parse(e.to_string()))?;
|
||||
|
||||
const MAX_OPERATIONS: usize = 1_000_000;
|
||||
if content.operations.len() > MAX_OPERATIONS {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — {} operations exceeds limit ({})",
|
||||
page_num,
|
||||
content.operations.len(),
|
||||
MAX_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false));
|
||||
}
|
||||
|
||||
// Graphics state tracking
|
||||
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
|
||||
let mut text_rendering_mode: i32 = 0; // 0=fill, 1=stroke, 2=fill+stroke, 3=invisible
|
||||
// Invisible (Tr 3) text was present but suppressed — reported to callers
|
||||
// so an include_invisible retry is attempted only when it can recover.
|
||||
let mut skipped_invisible = false;
|
||||
let mut line_width: f32 = 1.0;
|
||||
#[derive(Clone)]
|
||||
struct SavedGraphicsState {
|
||||
@@ -501,14 +494,6 @@ pub(crate) fn extract_page_text_items(
|
||||
// For Mixed/template PDFs, include_invisible=true extracts
|
||||
// the OCR text layer that sits behind scanned images.
|
||||
if text_rendering_mode == 3 && !include_invisible {
|
||||
if op
|
||||
.operands
|
||||
.first()
|
||||
.and_then(get_operand_bytes)
|
||||
.is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
if let Some(w_ts) = w_ts_opt {
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -580,16 +565,6 @@ pub(crate) fn extract_page_text_items(
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if let Ok(array) = op.operands[0].as_array() {
|
||||
let font_info = font_widths.get(¤t_font);
|
||||
// Numeric-only TJ arrays (pure kerning) show no
|
||||
// text — they must not trigger the invisible retry.
|
||||
if text_rendering_mode == 3
|
||||
&& !include_invisible
|
||||
&& array
|
||||
.iter()
|
||||
.any(|el| get_operand_bytes(el).is_some_and(|raw| !raw.is_empty()))
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
let is_invisible = (text_rendering_mode == 3 && !include_invisible)
|
||||
|| suppress_glyph_extraction;
|
||||
// Capture first-glyph position for ActualText
|
||||
@@ -795,16 +770,6 @@ pub(crate) fn extract_page_text_items(
|
||||
)
|
||||
})
|
||||
});
|
||||
if text_rendering_mode == 3
|
||||
&& !include_invisible
|
||||
&& op
|
||||
.operands
|
||||
.first()
|
||||
.and_then(get_operand_bytes)
|
||||
.is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
if !((text_rendering_mode == 3 && !include_invisible)
|
||||
|| suppress_glyph_extraction
|
||||
|| op.operands.is_empty())
|
||||
@@ -917,7 +882,6 @@ pub(crate) fn extract_page_text_items(
|
||||
&ctm,
|
||||
&mut cmap_decisions,
|
||||
style_cache,
|
||||
form_budget,
|
||||
);
|
||||
items.extend(form_items);
|
||||
}
|
||||
@@ -1287,12 +1251,6 @@ pub(crate) fn extract_page_text_items(
|
||||
}
|
||||
}
|
||||
|
||||
if form_budget.was_truncated() {
|
||||
log::warn!(
|
||||
"page {page_num}: Form XObject expansion truncated (invocation or operation budget reached); nested form text may be incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
// Underline detection reads only painted ink: `re` rects confirmed by
|
||||
// a paint operator plus filled-subpath rects — never clip-only rects,
|
||||
// which draw nothing.
|
||||
@@ -1342,12 +1300,7 @@ pub(crate) fn extract_page_text_items(
|
||||
|
||||
let items = super::merge_text_items(items);
|
||||
let items = super::merge_subscript_items(items);
|
||||
Ok((
|
||||
(items, rects, lines),
|
||||
has_gid_fonts,
|
||||
coords_rotated,
|
||||
skipped_invisible,
|
||||
))
|
||||
Ok(((items, rects, lines), has_gid_fonts, coords_rotated))
|
||||
}
|
||||
|
||||
/// Counts of text operators with horizontal vs rotated combined matrices.
|
||||
@@ -1545,14 +1498,13 @@ mod tests {
|
||||
|
||||
let (doc, page_id) = simple_doc_with_content(content);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
let ((items, _, _), _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
items
|
||||
@@ -1782,10 +1734,9 @@ BT /F1 12 Tf 0 1 -1 0 240 100 Tm (WORLD) Tj ET
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated, _skipped_invisible) = result;
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated) = result;
|
||||
assert!(items.is_empty());
|
||||
assert!(rects.is_empty());
|
||||
assert!(lines.is_empty());
|
||||
@@ -1866,14 +1817,13 @@ BT 30 700 Tm <41> Tj ET";
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
let ((items, _, _), _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = items
|
||||
@@ -1937,19 +1887,4 @@ BT 30 700 Tm <41> Tj ET";
|
||||
let output = strip_pdf_comments(input);
|
||||
assert_eq!(output, b"(x\\\\) Tj \nET\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_content_stream_skips_extraction() {
|
||||
let mut content =
|
||||
Vec::with_capacity((super::super::content_decode::MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=super::super::content_decode::MAX_PAGE_OPERATIONS {
|
||||
content.extend_from_slice(b"q\n");
|
||||
}
|
||||
content.extend_from_slice(b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
|
||||
let items = extract_simple_items(&content);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"pages over the operator cap must not be decoded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-107
@@ -482,11 +482,7 @@ pub(crate) fn parse_cid_w_array(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
) {
|
||||
let mut i = 0;
|
||||
let mut assigned = 0usize;
|
||||
while i < w_array.len() {
|
||||
if assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
let start_cid = match &w_array[i] {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
@@ -505,14 +501,12 @@ pub(crate) fn parse_cid_w_array(
|
||||
Object::Array(arr) => {
|
||||
// [c [w1 w2 ...]] — consecutive widths starting at c
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -520,14 +514,12 @@ pub(crate) fn parse_cid_w_array(
|
||||
// Could be a reference to an array
|
||||
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -550,8 +542,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -569,8 +561,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -581,45 +573,6 @@ pub(crate) fn parse_cid_w_array(
|
||||
}
|
||||
}
|
||||
|
||||
fn assign_cid_width(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
cid: u16,
|
||||
w_obj: &Object,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => return true,
|
||||
};
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn assign_cid_width_range(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
start: u16,
|
||||
end: u16,
|
||||
w: u16,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
if start > end {
|
||||
return true;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Compute the width of a string in text space units,
|
||||
/// given raw bytes and font width info.
|
||||
/// Returns width in text space units (font_units * units_scale * font_size).
|
||||
@@ -2360,48 +2313,4 @@ end",
|
||||
// invalid CMap result — so it must not clear the gid flag.
|
||||
assert!(gid_flagged(Some("<01> <FFFD>\n<02> <FFFD>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_range_and_consecutive() {
|
||||
use super::parse_cid_w_array;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let w = vec![
|
||||
Object::Integer(10),
|
||||
Object::Integer(12),
|
||||
Object::Integer(500),
|
||||
Object::Integer(20),
|
||||
Object::Array(vec![Object::Integer(100), Object::Integer(200)]),
|
||||
];
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert_eq!(widths.get(&10), Some(&500));
|
||||
assert_eq!(widths.get(&11), Some(&500));
|
||||
assert_eq!(widths.get(&12), Some(&500));
|
||||
assert_eq!(widths.get(&20), Some(&100));
|
||||
assert_eq!(widths.get(&21), Some(&200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_repeated_full_ranges_stay_bounded() {
|
||||
use super::parse_cid_w_array;
|
||||
use crate::tounicode::MAX_CID_W_EXPANSION;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert!(widths.len() <= MAX_CID_W_EXPANSION);
|
||||
assert_eq!(widths.get(&0), Some(&500));
|
||||
assert_eq!(widths.get(&65535), Some(&500));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -3,7 +3,6 @@
|
||||
//! This module extracts text with position information for structure detection.
|
||||
|
||||
mod base14;
|
||||
mod content_decode;
|
||||
pub(crate) mod content_stream;
|
||||
mod fonts;
|
||||
mod layout;
|
||||
@@ -38,7 +37,6 @@ pub(crate) use layout::group_prefiltered_items_into_lines_with_thresholds_and_re
|
||||
pub(crate) use layout::is_newspaper_layout;
|
||||
pub(crate) use layout::ColumnRegion;
|
||||
pub use layout::{group_into_lines, group_into_lines_preserving_all_text};
|
||||
pub(crate) use xobjects::FormWalkBudget;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
@@ -284,22 +282,18 @@ fn extract_positioned_text_impl(
|
||||
font_cmaps,
|
||||
include_invisible,
|
||||
&mut style_cache,
|
||||
&mut FormWalkBudget::new(),
|
||||
);
|
||||
let ((mut items, mut rects, mut lines), has_gid_fonts, coords_rotated, _skipped_invisible) =
|
||||
match page_result {
|
||||
Ok(extraction) => extraction,
|
||||
Err(error)
|
||||
if required_pages.is_some_and(|required| !required.contains(page_num)) =>
|
||||
{
|
||||
debug!(
|
||||
"page {}: skipping context-only extraction error: {}",
|
||||
page_num, error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let ((mut items, mut rects, mut lines), has_gid_fonts, coords_rotated) = match page_result {
|
||||
Ok(extraction) => extraction,
|
||||
Err(error) if required_pages.is_some_and(|required| !required.contains(page_num)) => {
|
||||
debug!(
|
||||
"page {}: skipping context-only extraction error: {}",
|
||||
page_num, error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
// Clip to the visible page box: single-page extracts and imposed
|
||||
// spreads keep neighboring pages' content in the stream, positioned
|
||||
// outside the CropBox. Extracting it interleaves invisible text into
|
||||
|
||||
+21
-600
@@ -16,76 +16,6 @@ use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
|
||||
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
|
||||
|
||||
/// Upper bound on Form XObject invocations during a single page extraction.
|
||||
/// Depth alone is not enough: an acyclic DAG where each form invokes the next
|
||||
/// N times expands to N^depth work before the depth cap is reached.
|
||||
const MAX_FORM_XOBJECT_INVOCATIONS: usize = 10_000;
|
||||
|
||||
/// Upper bound on content-stream operations walked across all Form XObject
|
||||
/// expansions for a page. Nested forms are decoded independently of the
|
||||
/// page-level operation cap, so this keeps total form work in the same
|
||||
/// ballpark as that page cap.
|
||||
const MAX_FORM_XOBJECT_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Shared budget for Form XObject expansion on a page. Bounds both nested DAG
|
||||
/// expansion and repeated sibling `/Do` invocations of the same form.
|
||||
pub(crate) struct FormWalkBudget {
|
||||
invocations: usize,
|
||||
operations: usize,
|
||||
max_invocations: usize,
|
||||
max_operations: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl FormWalkBudget {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::with_limits(MAX_FORM_XOBJECT_INVOCATIONS, MAX_FORM_XOBJECT_OPERATIONS)
|
||||
}
|
||||
|
||||
fn with_limits(max_invocations: usize, max_operations: usize) -> Self {
|
||||
Self {
|
||||
invocations: 0,
|
||||
operations: 0,
|
||||
max_invocations,
|
||||
max_operations,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exhausted(&mut self) -> bool {
|
||||
if self.invocations >= self.max_invocations || self.operations >= self.max_operations {
|
||||
self.truncated = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn charge_invocation(&mut self) -> bool {
|
||||
if self.exhausted() {
|
||||
return false;
|
||||
}
|
||||
self.invocations += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge one walked content-stream operator. Independent of the
|
||||
/// invocation cap so a form that was already admitted can finish its
|
||||
/// stream (up to the operation cap).
|
||||
fn charge_operation(&mut self) -> bool {
|
||||
if self.operations >= self.max_operations {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
self.operations += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn was_truncated(&self) -> bool {
|
||||
self.truncated
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum XObjectType {
|
||||
Image,
|
||||
Form(ObjectId),
|
||||
@@ -179,7 +109,6 @@ fn collect_xobjects_from_dict(
|
||||
}
|
||||
|
||||
/// Extract text items from a Form XObject
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn extract_form_xobject_text(
|
||||
doc: &Document,
|
||||
form_id: ObjectId,
|
||||
@@ -188,7 +117,6 @@ pub(crate) fn extract_form_xobject_text(
|
||||
parent_ctm: &[f32; 6],
|
||||
cmap_decisions: &mut CMapDecisionCache,
|
||||
style_cache: &mut FontStyleCache,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
extract_form_xobject_text_inner(
|
||||
doc,
|
||||
@@ -199,7 +127,6 @@ pub(crate) fn extract_form_xobject_text(
|
||||
cmap_decisions,
|
||||
style_cache,
|
||||
0,
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,13 +140,10 @@ fn extract_form_xobject_text_inner(
|
||||
cmap_decisions: &mut CMapDecisionCache,
|
||||
style_cache: &mut FontStyleCache,
|
||||
depth: u8,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
let mut items = Vec::new();
|
||||
use lopdf::content::Content;
|
||||
|
||||
if !budget.charge_invocation() {
|
||||
return items;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Get the Form XObject stream
|
||||
let Ok(Object::Stream(stream)) = doc.get_object(form_id) else {
|
||||
@@ -232,12 +156,8 @@ fn extract_form_xobject_text_inner(
|
||||
Err(_) => stream.content.clone(),
|
||||
};
|
||||
|
||||
// Decode the content stream. Cap before lopdf materializes the operator
|
||||
// vector — the walk budget cannot help if decode itself allocates first.
|
||||
let Ok(Some(content)) = super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
) else {
|
||||
// Decode the content stream
|
||||
let Ok(content) = Content::decode(&content_data) else {
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -326,55 +246,19 @@ fn extract_form_xobject_text_inner(
|
||||
let mut current_font = String::new();
|
||||
let mut current_font_size: f32 = 12.0;
|
||||
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
// Text line matrix (TLM) — Td/TD/T* move relative to the start of the
|
||||
// current line, not to the position left by the last show operator.
|
||||
let mut line_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let mut text_leading: f32 = 0.0; // TL parameter (text-space units)
|
||||
let mut char_spacing: f32 = 0.0; // Tc parameter
|
||||
let mut word_spacing: f32 = 0.0; // Tw parameter
|
||||
let mut in_text_block = false;
|
||||
let mut fill_is_white = false;
|
||||
let mut ctm = base_ctm;
|
||||
|
||||
// Text state (Tc/Tw/TL/Tf) and the fill colour are part of the graphics
|
||||
// state and must be saved/restored by q/Q alongside the CTM.
|
||||
#[derive(Clone)]
|
||||
struct GraphicsState {
|
||||
ctm: [f32; 6],
|
||||
char_spacing: f32,
|
||||
word_spacing: f32,
|
||||
text_leading: f32,
|
||||
current_font: String,
|
||||
current_font_size: f32,
|
||||
fill_is_white: bool,
|
||||
}
|
||||
let mut ctm_stack: Vec<GraphicsState> = Vec::new();
|
||||
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
|
||||
|
||||
for op in &content.operations {
|
||||
if !budget.charge_operation() {
|
||||
break;
|
||||
}
|
||||
match op.operator.as_str() {
|
||||
"q" => {
|
||||
ctm_stack.push(GraphicsState {
|
||||
ctm,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
text_leading,
|
||||
current_font: current_font.clone(),
|
||||
current_font_size,
|
||||
fill_is_white,
|
||||
});
|
||||
ctm_stack.push(ctm);
|
||||
}
|
||||
"Q" => {
|
||||
if let Some(saved) = ctm_stack.pop() {
|
||||
ctm = saved.ctm;
|
||||
char_spacing = saved.char_spacing;
|
||||
word_spacing = saved.word_spacing;
|
||||
text_leading = saved.text_leading;
|
||||
current_font = saved.current_font;
|
||||
current_font_size = saved.current_font_size;
|
||||
fill_is_white = saved.fill_is_white;
|
||||
ctm = saved;
|
||||
}
|
||||
}
|
||||
"cm" => {
|
||||
@@ -392,7 +276,7 @@ fn extract_form_xobject_text_inner(
|
||||
let xobj_name = String::from_utf8_lossy(name).to_string();
|
||||
match form_xobjects.get(&xobj_name) {
|
||||
Some(XObjectType::Form(nested_id)) => {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH && !budget.exhausted() {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH {
|
||||
let nested_items = extract_form_xobject_text_inner(
|
||||
doc,
|
||||
*nested_id,
|
||||
@@ -402,7 +286,6 @@ fn extract_form_xobject_text_inner(
|
||||
cmap_decisions,
|
||||
style_cache,
|
||||
depth + 1,
|
||||
budget,
|
||||
);
|
||||
items.extend(nested_items);
|
||||
}
|
||||
@@ -438,7 +321,6 @@ fn extract_form_xobject_text_inner(
|
||||
"BT" => {
|
||||
in_text_block = true;
|
||||
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
"ET" => {
|
||||
in_text_block = false;
|
||||
@@ -451,33 +333,12 @@ fn extract_form_xobject_text_inner(
|
||||
current_font_size = get_number(&op.operands[1]).unwrap_or(12.0);
|
||||
}
|
||||
}
|
||||
"TL" => {
|
||||
// Set text leading (used by T*, ', and ")
|
||||
if let Some(tl) = op.operands.first().and_then(get_number) {
|
||||
text_leading = tl;
|
||||
}
|
||||
}
|
||||
"Tc" => {
|
||||
if let Some(tc) = op.operands.first().and_then(get_number) {
|
||||
char_spacing = tc;
|
||||
}
|
||||
}
|
||||
"Tw" => {
|
||||
if let Some(tw) = op.operands.first().and_then(get_number) {
|
||||
word_spacing = tw;
|
||||
}
|
||||
}
|
||||
"Td" | "TD" => {
|
||||
// Move text position: TLM = T(tx,ty) x TLM; Tm = TLM
|
||||
if op.operands.len() >= 2 {
|
||||
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
|
||||
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
|
||||
line_matrix[4] += tx * line_matrix[0] + ty * line_matrix[2];
|
||||
line_matrix[5] += tx * line_matrix[1] + ty * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
if op.operator == "TD" {
|
||||
text_leading = -ty;
|
||||
}
|
||||
text_matrix[4] += tx * text_matrix[0] + ty * text_matrix[2];
|
||||
text_matrix[5] += tx * text_matrix[1] + ty * text_matrix[3];
|
||||
}
|
||||
}
|
||||
"Tm" => {
|
||||
@@ -486,20 +347,8 @@ fn extract_form_xobject_text_inner(
|
||||
text_matrix[i] =
|
||||
get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
|
||||
}
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
}
|
||||
"T*" => {
|
||||
// Move to start of next line: equivalent to `0 -TL Td`
|
||||
let tl = if text_leading != 0.0 {
|
||||
text_leading
|
||||
} else {
|
||||
current_font_size * 1.2
|
||||
};
|
||||
line_matrix[4] += (-tl) * line_matrix[2];
|
||||
line_matrix[5] += (-tl) * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
"g" => {
|
||||
if let Some(gray) = op.operands.first().and_then(get_number) {
|
||||
fill_is_white = gray > 0.95;
|
||||
@@ -535,33 +384,17 @@ fn extract_form_xobject_text_inner(
|
||||
_ => fill_is_white = false,
|
||||
}
|
||||
}
|
||||
"Tj" | "'" | "\"" => {
|
||||
// `'` = move to next line then show; `"` = set word/char spacing,
|
||||
// move to next line, then show (string is the last operand).
|
||||
if op.operator != "Tj" {
|
||||
if op.operator == "\"" && op.operands.len() >= 3 {
|
||||
word_spacing = get_number(&op.operands[0]).unwrap_or(word_spacing);
|
||||
char_spacing = get_number(&op.operands[1]).unwrap_or(char_spacing);
|
||||
}
|
||||
let tl = if text_leading != 0.0 {
|
||||
text_leading
|
||||
} else {
|
||||
current_font_size * 1.2
|
||||
};
|
||||
line_matrix[4] += (-tl) * line_matrix[2];
|
||||
line_matrix[5] += (-tl) * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
if let (true, Some(show_operand)) = (in_text_block, op.operands.last()) {
|
||||
"Tj" => {
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if fill_is_white {
|
||||
if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
raw_bytes,
|
||||
font_info,
|
||||
current_font_size,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -570,7 +403,7 @@ fn extract_form_xobject_text_inner(
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = extract_text_from_operand(
|
||||
show_operand,
|
||||
&op.operands[0],
|
||||
¤t_font,
|
||||
font_base_names.get(¤t_font).map(|s| s.as_str()),
|
||||
font_cmaps,
|
||||
@@ -586,13 +419,13 @@ fn extract_form_xobject_text_inner(
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
raw_bytes,
|
||||
font_info,
|
||||
current_font_size,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -715,8 +548,8 @@ fn extract_form_xobject_text_inner(
|
||||
raw_bytes,
|
||||
fi,
|
||||
current_font_size,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -850,415 +683,3 @@ pub(crate) fn get_form_fonts<'a>(
|
||||
|
||||
fonts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extractor::content_stream::extract_page_text_items;
|
||||
use lopdf::{dictionary, Dictionary, Stream};
|
||||
|
||||
/// Build an acyclic Form XObject DAG: `levels` form objects, each non-leaf
|
||||
/// invoking the next form `branches` times. The leaf draws a single `(X)`.
|
||||
/// Returns `(doc, root_form_id)`.
|
||||
fn form_dag(branches: usize, levels: usize) -> (Document, ObjectId) {
|
||||
assert!(levels >= 2);
|
||||
let mut doc = Document::new();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
});
|
||||
let ids: Vec<ObjectId> = (0..levels).map(|_| doc.new_object_id()).collect();
|
||||
for level in 0..levels {
|
||||
let stream = if level + 1 == levels {
|
||||
Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
b"BT /F1 10 Tf 10 10 Td (X) Tj ET\n".to_vec(),
|
||||
)
|
||||
} else {
|
||||
let next_name = format!("Fm{}", level + 1);
|
||||
let content = format!("/{next_name} Do\n").repeat(branches);
|
||||
let mut xobjects = Dictionary::new();
|
||||
xobjects.set(next_name, Object::Reference(ids[level + 1]));
|
||||
let mut resources = Dictionary::new();
|
||||
resources.set("XObject", Object::Dictionary(xobjects));
|
||||
let mut dict = dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
};
|
||||
dict.set("Resources", Object::Dictionary(resources));
|
||||
Stream::new(dict, content.into_bytes())
|
||||
};
|
||||
doc.set_object(ids[level], Object::Stream(stream));
|
||||
}
|
||||
(doc, ids[0])
|
||||
}
|
||||
|
||||
fn page_invoking_form(mut doc: Document, form_id: ObjectId) -> (Document, ObjectId) {
|
||||
let content_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
b"/Fm0 Do\n".to_vec(),
|
||||
)));
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Contents" => Object::Reference(content_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! {
|
||||
"Fm0" => Object::Reference(form_id),
|
||||
},
|
||||
},
|
||||
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
});
|
||||
let pages_id = doc.add_object(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Count" => Object::Integer(1),
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
fn extract_form(
|
||||
doc: &Document,
|
||||
form_id: ObjectId,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
extract_form_xobject_text(
|
||||
doc,
|
||||
form_id,
|
||||
1,
|
||||
&FontCMaps::from_doc(doc),
|
||||
&[1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
|
||||
&mut CMapDecisionCache::new(),
|
||||
&mut FontStyleCache::new(),
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_form_still_extracts_leaf_text() {
|
||||
let (doc, root) = form_dag(1, 3);
|
||||
let items = extract_form(&doc, root, &mut FormWalkBudget::new());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].text, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acyclic_form_dag_within_budget_keeps_all_leaves() {
|
||||
// 4 sibling invocations across 4 nested levels → 4^4 leaf drawings.
|
||||
// Default budgets are far above 256, so legitimate nesting is intact.
|
||||
let (doc, root) = form_dag(4, 5);
|
||||
let items = extract_form(&doc, root, &mut FormWalkBudget::new());
|
||||
assert_eq!(items.len(), 4usize.pow(4));
|
||||
assert!(items.iter().all(|item| item.text == "X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acyclic_form_dag_stops_at_invocation_budget() {
|
||||
// Same DAG as above would draw 256 leaves; a tiny invocation cap must
|
||||
// stop expansion rather than walking the full tree.
|
||||
let (doc, root) = form_dag(4, 5);
|
||||
let mut budget = FormWalkBudget::with_limits(20, MAX_FORM_XOBJECT_OPERATIONS);
|
||||
let items = extract_form(&doc, root, &mut budget);
|
||||
assert!(
|
||||
items.len() < 4usize.pow(4),
|
||||
"invocation budget must truncate DAG expansion; got {} items",
|
||||
items.len()
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_operations_stop_at_budget() {
|
||||
let mut doc = Document::new();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
});
|
||||
let mut content = b"q Q\n".repeat(50);
|
||||
content.extend_from_slice(b"BT /F1 10 Tf 10 10 Td (X) Tj ET\n");
|
||||
let form_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
content,
|
||||
)));
|
||||
|
||||
let mut budget = FormWalkBudget::with_limits(MAX_FORM_XOBJECT_INVOCATIONS, 10);
|
||||
let items = extract_form(&doc, form_id, &mut budget);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"operation budget must stop before the trailing text show"
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_level_form_dag_stays_within_production_budget() {
|
||||
// A page-level `/Do` of an 8-wide, 6-level Form DAG would expand to
|
||||
// 8^5 = 32_768 leaf drawings without a budget. The production
|
||||
// invocation cap must keep extraction bounded.
|
||||
let (doc, root) = form_dag(8, 6);
|
||||
let (doc, page_id) = page_invoking_form(doc, root);
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
items.len() <= MAX_FORM_XOBJECT_INVOCATIONS,
|
||||
"page-level Form expansion must stay within the invocation cap; got {}",
|
||||
items.len()
|
||||
);
|
||||
assert!(
|
||||
!items.is_empty(),
|
||||
"budget must still allow some nested form text through"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_form_budget_spans_two_extraction_passes() {
|
||||
// The invisible-layer retry calls extract_page_text_items twice for
|
||||
// the same page; both passes must share one budget.
|
||||
let (doc, root) = form_dag(1, 2);
|
||||
let (doc, page_id) = page_invoking_form(doc, root);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
// Root + leaf = 2 invocations on the first pass.
|
||||
let mut budget = FormWalkBudget::with_limits(2, MAX_FORM_XOBJECT_OPERATIONS);
|
||||
let ((first, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut budget,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.iter().filter(|item| item.text == "X").count(), 1);
|
||||
assert!(!budget.was_truncated());
|
||||
|
||||
let ((second, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
true,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut budget,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
second.iter().all(|item| item.text != "X"),
|
||||
"second pass must not get a fresh invocation budget"
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
/// Build a document whose page draws *all* of its content through a single
|
||||
/// Form XObject — the shape emitted by print-to-PDF producers like PDFlib,
|
||||
/// where the page stream itself is only `q /X1 Do Q`.
|
||||
fn doc_with_form_content(form_content: &[u8]) -> (Document, ObjectId) {
|
||||
let mut doc = Document::new();
|
||||
let widths: Vec<Object> = (0..=255).map(|_| 600.into()).collect();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
"FirstChar" => 0,
|
||||
"LastChar" => 255,
|
||||
"Widths" => Object::Array(widths),
|
||||
});
|
||||
let form_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
|
||||
},
|
||||
},
|
||||
form_content.to_vec(),
|
||||
)));
|
||||
let content_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
b"q /X1 Do Q".to_vec(),
|
||||
)));
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Contents" => Object::Reference(content_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! { "X1" => Object::Reference(form_id) },
|
||||
},
|
||||
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
});
|
||||
let pages_id = doc.add_object(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Count" => Object::Integer(1),
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
fn form_items(form_content: &[u8]) -> Vec<TextItem> {
|
||||
let (doc, page_id) = doc_with_form_content(form_content);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
items
|
||||
}
|
||||
|
||||
fn find<'a>(items: &'a [TextItem], text: &str) -> &'a TextItem {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.text == text)
|
||||
.unwrap_or_else(|| {
|
||||
let found: Vec<&String> = items.iter().map(|i| &i.text).collect();
|
||||
panic!("no item {text:?} in {found:?}")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_star_inside_form_moves_to_next_line() {
|
||||
// T* was previously unhandled inside Form XObjects, so every line after
|
||||
// the first piled onto the preceding baseline and drifted right.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj T* (second) Tj ET");
|
||||
|
||||
let first = find(&items, "first");
|
||||
let second = find(&items, "second");
|
||||
assert!((first.y - 700.0).abs() < 0.1, "first y = {}", first.y);
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn td_inside_form_is_relative_to_line_start_not_shown_text() {
|
||||
// Td moves relative to the text *line* matrix. Applying it to the
|
||||
// matrix already advanced by Tj marched each line off the right edge.
|
||||
let items = form_items(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (AAAAA) Tj 0 -12 Td (B) Tj ET");
|
||||
|
||||
let b = find(&items, "B");
|
||||
assert!((b.x - 100.0).abs() < 0.1, "B x = {} (expected 100)", b.x);
|
||||
assert!((b.y - 688.0).abs() < 0.1, "B y = {}", b.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn td_inside_form_sets_leading_for_later_t_star() {
|
||||
// `TD` sets the leading to -ty as a side effect; a following T* must
|
||||
// reuse it.
|
||||
let items = form_items(
|
||||
b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (one) Tj 0 -15 TD (two) Tj T* (three) Tj ET",
|
||||
);
|
||||
|
||||
assert!((find(&items, "two").y - 685.0).abs() < 0.1);
|
||||
let three = find(&items, "three");
|
||||
assert!((three.y - 670.0).abs() < 0.1, "three y = {}", three.y);
|
||||
assert!((three.x - 100.0).abs() < 0.1, "three x = {}", three.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_operator_inside_form_moves_to_next_line() {
|
||||
let items = form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj (second) ' ET");
|
||||
|
||||
let second = find(&items, "second");
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_quote_operator_inside_form_sets_spacing_and_moves() {
|
||||
// `aw ac (string) "` — set word spacing and char spacing, then T* and show.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj 0 0 (second) \" ET");
|
||||
|
||||
let second = find(&items, "second");
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_spacing_inside_form_widens_advance() {
|
||||
// Tc was hardcoded to 0 in the form parser, so advance widths drifted.
|
||||
// 2 glyphs x 600/1000 x 12pt = 14.4, plus 2 x Tc(2.0) = 18.4.
|
||||
let items = form_items(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm 2 Tc (AB) Tj ET");
|
||||
|
||||
let ab = find(&items, "AB");
|
||||
assert!((ab.width - 18.4).abs() < 0.1, "AB width = {}", ab.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_fill_colour_inside_form() {
|
||||
// A white fill set inside q/Q must not leak past the Q — otherwise the
|
||||
// following black text is treated as invisible and dropped entirely.
|
||||
let items = form_items(
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm q 1 g (hidden) Tj Q T* (visible) Tj ET",
|
||||
);
|
||||
|
||||
assert!(
|
||||
items.iter().any(|item| item.text == "visible"),
|
||||
"text after Q was dropped: {:?}",
|
||||
items.iter().map(|i| &i.text).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!items.iter().any(|item| item.text == "hidden"),
|
||||
"white-filled text should still be suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_text_state_inside_form() {
|
||||
// Tc/TL live in the graphics state; `Q` must roll them back.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm q 30 TL (a) Tj Q T* (b) Tj ET");
|
||||
|
||||
let b = find(&items, "b");
|
||||
assert!(
|
||||
(b.y - 688.0).abs() < 0.1,
|
||||
"b y = {} (leading should restore to 12)",
|
||||
b.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-99
@@ -757,23 +757,6 @@ pub struct PageRegionResult {
|
||||
pub regions: Vec<RegionText>,
|
||||
}
|
||||
|
||||
/// Minimum alphanumeric mass an invisible (Tr 3) text layer must carry for
|
||||
/// the OCR-layer fallback in [`extract_text_in_regions_mem`] to adopt it. A
|
||||
/// real OCR layer carries far more; a stray watermark or artifact does not.
|
||||
const OCR_LAYER_MIN_ALNUM: usize = 40;
|
||||
|
||||
/// Alphanumeric mass of extracted items, ignoring raster placeholders.
|
||||
/// `[Image: ...]` items (ItemType::Image) are synthesized for image
|
||||
/// XObjects — they mark that pixels exist, not that text was read, so they
|
||||
/// must not count as coverage.
|
||||
fn non_placeholder_alnum(items: &[TextItem]) -> usize {
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| !matches!(it.item_type, types::ItemType::Image))
|
||||
.map(|it| it.text.chars().filter(|c| c.is_alphanumeric()).count())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Extract text within bounding-box regions from a PDF in memory.
|
||||
///
|
||||
/// This is designed for hybrid OCR pipelines: a layout model detects regions
|
||||
@@ -826,11 +809,8 @@ pub fn extract_text_in_regions_mem(
|
||||
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
// Extract text items for this page. The Form XObject budget is shared
|
||||
// with the invisible-layer retry below so one page cannot consume two
|
||||
// full expansion budgets.
|
||||
let mut form_budget = extractor::FormWalkBudget::new();
|
||||
let ((mut items, _rects, _lines), mut has_gid, mut coords_rotated, skipped_invisible) =
|
||||
// Extract text items for this page
|
||||
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -838,54 +818,7 @@ pub fn extract_text_in_regions_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut style_cache,
|
||||
&mut form_budget,
|
||||
)?;
|
||||
// OCR-layer fallback: scanned pages often carry their text as an
|
||||
// invisible (Tr 3) layer behind the page raster. The visible-only
|
||||
// pass sees nothing there but `[Image: ...]` placeholders, so every
|
||||
// region on the page reports needs_ocr even though the exact text is
|
||||
// embedded in the PDF — and this extractor then disagrees with the
|
||||
// markdown path, which already retries Mixed PDFs with the invisible
|
||||
// layer included. Retry page-scoped, and only when (a) the first
|
||||
// pass actually SKIPPED invisible text — blank pages and image-only
|
||||
// scans without an OCR layer must not pay a second content-stream
|
||||
// parse (review catch) — and (b) the page has NO visible text item
|
||||
// at all (punctuation counts, whitespace-only artifacts don't): an
|
||||
// invisible OCR layer transcribes the raster, so any visible glyph
|
||||
// has an invisible twin there and adoption would duplicate it
|
||||
// (review catches — strict gate, no fuzzy dedupe). Adopt the retry
|
||||
// only when it contributes real, non-garbage text.
|
||||
let has_visible_text = items.iter().any(|it| {
|
||||
!matches!(it.item_type, types::ItemType::Image) && !it.text.trim().is_empty()
|
||||
});
|
||||
if skipped_invisible && !has_visible_text {
|
||||
if let Ok(((inv_items, _inv_rects, _inv_lines), inv_gid, inv_rotated, _)) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
*page_num,
|
||||
&font_cmaps,
|
||||
true,
|
||||
&mut style_cache,
|
||||
&mut form_budget,
|
||||
)
|
||||
{
|
||||
let inv_alnum = non_placeholder_alnum(&inv_items);
|
||||
// Judge the WHOLE recovered layer, not a prefix — a broken
|
||||
// OCR layer can hide its garbage past any fixed sample size
|
||||
// (review catch).
|
||||
let sample: String = inv_items
|
||||
.iter()
|
||||
.filter(|it| !matches!(it.item_type, types::ItemType::Image))
|
||||
.map(|it| it.text.as_str())
|
||||
.collect();
|
||||
if inv_alnum >= OCR_LAYER_MIN_ALNUM && !is_garbage_text(&sample) {
|
||||
items = inv_items;
|
||||
has_gid = inv_gid;
|
||||
coords_rotated = inv_rotated;
|
||||
}
|
||||
}
|
||||
}
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
page_thresholds.insert(*page_num, threshold);
|
||||
@@ -1042,7 +975,7 @@ pub fn extract_tables_in_regions_mem(
|
||||
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
let ((mut items, rects, lines), has_gid, coords_rotated, _skipped_invisible) =
|
||||
let ((mut items, rects, lines), has_gid, coords_rotated) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -1050,7 +983,6 @@ pub fn extract_tables_in_regions_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut style_cache,
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
@@ -1354,7 +1286,7 @@ pub fn detect_vector_grid_in_region_mem(
|
||||
let needed_pages = HashSet::from([page_1idx]);
|
||||
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
|
||||
let page_h = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
let ((mut items, rects, lines), _has_gid, coords_rotated, _skipped_invisible) =
|
||||
let ((mut items, rects, lines), _has_gid, coords_rotated) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -1362,7 +1294,6 @@ pub fn detect_vector_grid_in_region_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut extractor::FontStyleCache::new(),
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
text_utils::fix_letterspaced_items(&mut items);
|
||||
|
||||
@@ -1549,17 +1480,15 @@ mod vector_grid_tests {
|
||||
let &page_id = pages.get(&1).unwrap();
|
||||
let needed: HashSet<u32> = HashSet::from([1]);
|
||||
let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||
let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
|
||||
extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
&mut crate::extractor::FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, _lines), _has_gid, _rotated) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (rect_tables, _) = detect_tables_from_rects(&items, &rects, 1);
|
||||
assert_eq!(rect_tables.len(), 1, "expected one rect-detected table");
|
||||
@@ -1593,17 +1522,15 @@ mod vector_grid_tests {
|
||||
let &page_id = pages.get(&page_num).unwrap();
|
||||
let needed: HashSet<u32> = HashSet::from([page_num]);
|
||||
let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||
let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
|
||||
extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
page_num,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
&mut crate::extractor::FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, _lines), _has_gid, _rotated) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
page_num,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (rect_tables, _) = detect_tables_from_rects(&items, &rects, page_num);
|
||||
rect_tables
|
||||
@@ -2331,7 +2258,7 @@ pub fn extract_tables_with_structure_cells_mem(
|
||||
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated, _skipped_invisible) =
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -2339,7 +2266,6 @@ pub fn extract_tables_with_structure_cells_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut style_cache,
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
@@ -3134,7 +3060,7 @@ fn detect_tsr_quality_issue(
|
||||
let mut needed: HashSet<u32> = HashSet::new();
|
||||
needed.insert(page_1idx);
|
||||
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated, _skipped_invisible) =
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -3142,7 +3068,6 @@ fn detect_tsr_quality_issue(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut extractor::FontStyleCache::new(),
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
let adaptive_threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
let coords = if coords_rotated {
|
||||
|
||||
+12
-73
@@ -1832,11 +1832,6 @@ fn merge_cmaps(mut base: ToUnicodeCMap, overlay: ToUnicodeCMap) -> ToUnicodeCMap
|
||||
base
|
||||
}
|
||||
|
||||
/// Upper bound on CID `/W` range expansion. The CID domain is 16-bit, so more
|
||||
/// than 65,536 unique keys cannot exist; repeating full-width ranges must not
|
||||
/// re-expand the same domain.
|
||||
pub(crate) const MAX_CID_W_EXPANSION: usize = 65_536;
|
||||
|
||||
/// Check if a CIDFont's /W (widths) array contains CID values that look like
|
||||
/// Unicode codepoints rather than low-value GIDs.
|
||||
///
|
||||
@@ -1848,23 +1843,20 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w].
|
||||
// Collect unique CIDs only: repeating a full-width range must not grow a
|
||||
// temporary vector (or the sort) with the range length on every copy.
|
||||
let mut seen = HashSet::new();
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w]
|
||||
// We extract all CID values (the first element of each group).
|
||||
let mut cids: Vec<u16> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < w_arr.len() && seen.len() < MAX_CID_W_EXPANSION {
|
||||
while i < w_arr.len() {
|
||||
if let Ok(cid) = w_arr[i].as_i64() {
|
||||
let start = cid as u16;
|
||||
cids.push(cid as u16);
|
||||
// Skip the width data
|
||||
if i + 1 < w_arr.len() {
|
||||
match &w_arr[i + 1] {
|
||||
Object::Array(widths) => {
|
||||
// [cid [w1 w2 ...]] — CIDs are cid, cid+1, ..., cid+len-1
|
||||
for j in 0..widths.len() {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
break;
|
||||
}
|
||||
seen.insert(start.wrapping_add(j as u16));
|
||||
for j in 1..widths.len() {
|
||||
cids.push((cid as u16).wrapping_add(j as u16));
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
@@ -1872,7 +1864,9 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
// [cid_start cid_end w] — range of CIDs
|
||||
if i + 2 < w_arr.len() {
|
||||
if let Ok(cid_end) = w_arr[i + 1].as_i64() {
|
||||
record_unique_cid_range(start, cid_end as u16, &mut seen);
|
||||
for c in (cid as u16)..=(cid_end as u16) {
|
||||
cids.push(c);
|
||||
}
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
@@ -1881,7 +1875,6 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seen.insert(start);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
@@ -1889,11 +1882,10 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
|
||||
if seen.is_empty() {
|
||||
if cids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut cids: Vec<u16> = seen.into_iter().collect();
|
||||
cids.sort_unstable();
|
||||
let median = cids[cids.len() / 2];
|
||||
// Unicode text CIDs are typically >= 0x20 (space) with letters at 0x41+.
|
||||
@@ -1902,18 +1894,6 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
median >= 0x41
|
||||
}
|
||||
|
||||
fn record_unique_cid_range(start: u16, end: u16, seen: &mut HashSet<u16>) {
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
seen.insert(cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ToUnicodeCMap from predefined CID→Unicode mapping based on CIDSystemInfo.
|
||||
///
|
||||
/// Supports Adobe-Korea1 (Korean) character collection. Can be extended for
|
||||
@@ -3318,45 +3298,4 @@ endbfrange
|
||||
"An indirect /Subtype naming CIDFontType2 must still reach the remap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_letter_range() {
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set(
|
||||
"W",
|
||||
Object::Array(vec![
|
||||
Object::Integer(0x41),
|
||||
Object::Integer(0x5A),
|
||||
Object::Integer(500),
|
||||
]),
|
||||
);
|
||||
assert!(cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_low_gids() {
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set(
|
||||
"W",
|
||||
Object::Array(vec![
|
||||
Object::Integer(0),
|
||||
Object::Array(vec![Object::Integer(500); 10]),
|
||||
]),
|
||||
);
|
||||
assert!(!cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_repeated_full_ranges_stay_bounded() {
|
||||
// Repeating `[0 65535 w]` must not materialize 65,536 CIDs per copy.
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set("W", Object::Array(w));
|
||||
assert!(cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1654,282 +1654,6 @@ fn test_extract_regions_mem_basic_text_pdf() {
|
||||
assert_eq!(regions[0].page, 0);
|
||||
}
|
||||
|
||||
/// Build a synthetic "scanned page" PDF: a full-page image XObject with a
|
||||
/// text layer drawn in the given render mode (3 = invisible OCR overlay,
|
||||
/// 0 = normal visible fill). `visible_extra` optionally adds a normally
|
||||
/// rendered line so double-layer behavior can be tested; `layer_lines`
|
||||
/// overrides the layer content (default: three pangram lines);
|
||||
/// `quote_ops` shows every layer line via the `'` operator instead of Tj
|
||||
/// (both are standard show-text encodings for OCR layers).
|
||||
fn make_pdf_with_custom_text_layer(
|
||||
text_render_mode: i32,
|
||||
visible_extra: Option<&str>,
|
||||
layer_lines: Option<&[&str]>,
|
||||
quote_ops: bool,
|
||||
) -> 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");
|
||||
}
|
||||
|
||||
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 >>",
|
||||
);
|
||||
// Full-page raster, then the text layer in the requested render mode —
|
||||
// several lines so the OCR-layer gate's alnum floor (40) is well cleared.
|
||||
let mut content = String::from("q 612 0 0 792 0 0 cm /Im0 Do Q\n");
|
||||
let default_layer = [
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"Pack my box with five dozen liquor jugs tonight",
|
||||
"Sphinx of black quartz judge my vow carefully",
|
||||
];
|
||||
let layer: &[&str] = layer_lines.unwrap_or(&default_layer);
|
||||
if quote_ops {
|
||||
// Every line shown via `'` (move-to-next-line + show) — nothing on
|
||||
// this layer goes through Tj, pinning the `'` suppression path.
|
||||
content.push_str(&format!(
|
||||
"BT /F1 12 Tf {text_render_mode} Tr 16 TL 72 716 Td "
|
||||
));
|
||||
for line in layer {
|
||||
content.push_str(&format!("({line}) ' "));
|
||||
}
|
||||
} else {
|
||||
content.push_str(&format!("BT /F1 12 Tf {text_render_mode} Tr 72 700 Td "));
|
||||
for (i, line) in layer.iter().enumerate() {
|
||||
if i > 0 {
|
||||
content.push_str("0 -16 Td ");
|
||||
}
|
||||
content.push_str(&format!("({line}) Tj "));
|
||||
}
|
||||
}
|
||||
content.push_str("ET\n");
|
||||
if let Some(extra) = visible_extra {
|
||||
content.push_str(&format!("BT /F1 12 Tf 0 Tr 72 500 Td ({extra}) Tj ET\n"));
|
||||
}
|
||||
add_stream_object(&mut pdf, &mut offsets, 4, "", content.as_bytes());
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
5,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
fn make_pdf_with_text_layer(text_render_mode: i32, visible_extra: Option<&str>) -> Vec<u8> {
|
||||
make_pdf_with_custom_text_layer(text_render_mode, visible_extra, None, false)
|
||||
}
|
||||
|
||||
/// A scanned page whose only text is an invisible (Tr 3) OCR layer behind
|
||||
/// the raster must serve that layer from the region extractor instead of
|
||||
/// reporting the region as needs_ocr — the exact text is already in the PDF.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_recovers_invisible_ocr_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, None);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("quick brown fox"),
|
||||
"invisible OCR layer should be served as region text, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
!region.needs_ocr,
|
||||
"recovered OCR layer must not fall back to GPU OCR"
|
||||
);
|
||||
}
|
||||
|
||||
/// ANY visible text on the page — even a single short line — must block the
|
||||
/// invisible-layer adoption entirely: the invisible pass returns visible
|
||||
/// items too, so adopting it alongside visible text would duplicate the
|
||||
/// visible words. Strict zero-visible gate, no fuzzy dedupe.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_visible_text_blocks_invisible_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, Some("Folio 142"));
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("Folio 142"),
|
||||
"visible text should be extracted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
!region.text.contains("quick brown fox"),
|
||||
"invisible layer must not be adopted when any visible text exists, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert_eq!(
|
||||
region.text.matches("Folio 142").count(),
|
||||
1,
|
||||
"visible text must appear exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// An invisible OCR layer shown entirely via the `'` show-text operator
|
||||
/// (move-to-next-line + show) must also be recovered — the skipped_invisible
|
||||
/// signal has to fire on every show-text path, not just Tj/TJ.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_recovers_quote_operator_layer() {
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, None, true);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("quick brown fox"),
|
||||
"'-operator OCR layer should be recovered, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(!region.needs_ocr);
|
||||
}
|
||||
|
||||
/// An invisible layer below the 40-alnum floor (a stray watermark line)
|
||||
/// must NOT be adopted — the region keeps its needs_ocr fallback.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_tiny_invisible_layer_not_adopted() {
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, Some(&["Scanned by ACME"]), false);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("Scanned by ACME"),
|
||||
"below-floor invisible layer must not be adopted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
// Only the raster placeholder remains — needs_ocr stays whatever main
|
||||
// reports for placeholder-only regions (false today; downstream
|
||||
// pipelines route placeholder-only text to OCR themselves, and this PR
|
||||
// deliberately does not change that contract).
|
||||
assert!(
|
||||
region.text.trim().starts_with("[Image:"),
|
||||
"region should hold only the raster placeholder, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// An invisible layer that clears the alnum floor but is mostly symbol
|
||||
/// garbage (a broken OCR run) must be rejected by the garbage gate.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_garbage_invisible_layer_not_adopted() {
|
||||
// Each line: 5 alphanumerics among 15 symbol chars. Ten lines clear the
|
||||
// 40-alnum floor (50 alnum) while staying well under the half-alnum
|
||||
// ratio is_garbage_text requires.
|
||||
let garbage_lines: Vec<&str> = vec!["a@@b%%c&&d==e~~"; 10];
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, Some(&garbage_lines), false);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("a@@b"),
|
||||
"garbage invisible layer must not be adopted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
region.text.trim().starts_with("[Image:"),
|
||||
"region should hold only the raster placeholder, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation-only visible text (zero alphanumerics) must ALSO block
|
||||
/// adoption — the gate is item-presence, not alphanumeric mass. (Real-world
|
||||
/// rationale: an invisible OCR layer transcribes the raster, so visible
|
||||
/// glyphs typically have invisible twins there; this fixture's layers are
|
||||
/// disjoint, so it pins the gate itself, not the duplication scenario.)
|
||||
#[test]
|
||||
fn test_extract_regions_mem_punctuation_visible_blocks_invisible_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, Some("... --- ..."));
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("quick brown fox"),
|
||||
"invisible layer must not be adopted over punctuation-only visible text, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert_eq!(
|
||||
region.text.matches("... --- ...").count(),
|
||||
1,
|
||||
"visible punctuation must be preserved exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard: a normal visible-text page (render mode 0) is served
|
||||
/// once and only once — if the fallback ever mis-fired here and merged a
|
||||
/// second pass, the phrase would duplicate.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_visible_layer_unchanged() {
|
||||
let buf = make_pdf_with_text_layer(0, None);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert_eq!(
|
||||
region.text.matches("quick brown fox").count(),
|
||||
1,
|
||||
"visible text must appear exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(!region.needs_ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_identity_h_needs_ocr() {
|
||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||
|
||||
Generated
+2
-2
@@ -724,7 +724,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"include_dir",
|
||||
@@ -740,7 +740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector-wasm"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector-wasm"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
edition = "2021"
|
||||
authors = ["Firecrawl Team"]
|
||||
description = "Browser WebAssembly bindings for pdf-inspector"
|
||||
|
||||
Reference in New Issue
Block a user