1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
use serde::{Deserialize, Serialize};
/// Configuration for LiteParse document parsing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteParseConfig {
/// OCR language code (Tesseract format: "eng", "fra", "deu", etc.).
pub ocr_language: String,
/// Whether OCR is enabled. When true, runs on text-sparse pages and embedded images.
pub ocr_enabled: bool,
/// HTTP OCR server URL (uses Tesseract if not provided)
pub ocr_server_url: Option<String>,
/// Extra HTTP headers sent with every request to `ocr_server_url`, as
/// `(name, value)` pairs. Use for auth, e.g. `("Authorization", "Bearer …")`.
/// Ignored when `ocr_server_url` is None.
pub ocr_server_headers: Vec<(String, String)>,
/// Path to tessdata directory. Falls back to TESSDATA_PREFIX env var if not set.
pub tessdata_path: Option<String>,
/// Maximum number of pages to parse.
pub max_pages: usize,
/// Specific pages to parse (e.g., "1-5,10,15-20"). None means all pages.
pub target_pages: Option<String>,
/// DPI for rendering pages (used for OCR and screenshots).
pub dpi: f32,
/// Output format.
pub output_format: OutputFormat,
/// Keep very small text that would normally be filtered out.
pub preserve_very_small_text: bool,
/// Password for encrypted/protected documents.
pub password: Option<String>,
/// Suppress progress output.
pub quiet: bool,
/// Number of concurrent OCR workers. Defaults to (number of CPU cores - 1), minimum 1.
pub num_workers: usize,
/// Controls how raster image references are surfaced in markdown output.
/// This does not enable embedded-image extraction.
pub image_mode: ImageMode,
/// Extract embedded image bytes and metadata into `ParseResult.images`.
/// Defaults to false. `ImageMode::Embed` also enables extraction for
/// backwards compatibility (see [`LiteParseConfig::effective_extract_images`]).
pub extract_images: bool,
/// Directory where extracted embedded images are written. Requires
/// `extract_images` to be true.
pub image_output_dir: Option<String>,
/// Extract hyperlink annotations and render them as `[text](url)` in
/// markdown output. Default on. Disable for benchmark parity with
/// plain-text ground truth (the GT corpora never use link syntax).
pub extract_links: bool,
/// Extract all PDF annotations into each parsed page. Default `false`.
/// This is independent of `extract_links`, which only controls Markdown
/// link reconstruction.
pub extract_annotations: bool,
/// Extract AcroForm widget fields and values. Default `false`.
#[serde(default)]
pub extract_form_fields: bool,
/// Extract the tagged-PDF logical structure tree. Default `false`.
#[serde(default)]
pub extract_structure_tree: bool,
/// Emit each page's `content_bounds`: the union bbox of its top-level
/// content objects in viewport coords (visible content extent). Default
/// `false` to keep the default output shape unchanged. Content bounds are
/// still computed internally for the white-fill heuristic whenever
/// `extract_vector_graphics` is on.
#[serde(default)]
pub extract_content_bounds: bool,
/// Extract raw XFA packets (name + XML content) from XFA form documents
/// into `ParseResult.xfa_packets`. Default `false`. Non-XFA documents
/// yield an empty list.
#[serde(default)]
pub extract_xfa_packets: bool,
/// Detect solid rectangles and thick lines in rendered page screenshots
/// and attach them to each `ScreenshotResult.rects`. Works on the raster,
/// so it also finds structure in scanned/flattened pages that have no
/// vector paths. Default `false` (adds a full-bitmap scan per page).
#[serde(default)]
pub detect_screenshot_rects: bool,
/// Draw AcroForm field appearances (filled values, checkbox states) into
/// rendered rasters (screenshots and OCR inputs). This initializes a
/// PDFium form-fill environment and runs the document's open/JS actions,
/// so it is off by default: plain parses should neither execute document
/// scripts nor change raster bytes for form-bearing PDFs.
#[serde(default)]
pub render_form_fields: bool,
/// Whether a systemic OCR failure (every OCR task failed *and* at least one
/// was a text-sparse page whose primary text source was OCR) aborts the
/// whole parse. Default `true`: surface the root cause instead of silently
/// emitting blank pages. Set `false` to keep already-recovered native text
/// and return partial results when OCR is unavailable — useful for callers
/// that prefer a degraded document over a hard failure (e.g. when the host
/// has its own OCR fallback or treats OCR as best-effort enrichment).
pub ocr_failure_fatal: bool,
/// OCR request-hedging schedule (milliseconds) for the HTTP OCR engine.
/// Empty (default) = no hedging. With multiple delays (e.g.
/// `[0, 5000, 10000, 15000, 20000]`), each OCR attempt fires a duplicate
/// request at every delay and takes the first to succeed — trading extra
/// OCR-server load for lower tail latency on a slow/stuck pod. No effect on
/// the Tesseract engine.
pub ocr_hedge_delays_ms: Vec<u64>,
/// Emit per-word sub-boxes on each `TextItem` (`TextItem.words`). Default
/// `false`: a text item already carries its own box, and word boxes roughly
/// double the text-item payload (size + napi marshalling), so they are only
/// worth computing for callers doing word-level bbox attribution. When
/// `false`, `TextItem.words` is always empty and the per-word tracking is
/// skipped entirely (zero allocation).
pub emit_word_boxes: bool,
/// Include rich PDF text metadata on public text items: MCID, glyph width,
/// font metrics/weight/buggy state, fill/stroke colors, raw character
/// codes, and generated-trailing-space state. Default `false` to preserve
/// LiteParse's lightweight response shape. Layout-required metrics remain
/// available internally; source character codes and generated-space state
/// are not computed unless this is enabled.
#[serde(default)]
pub extract_text_metadata: bool,
/// Restrict output to a sub-region of every page. Each field is the
/// fraction of the page to crop away from that side (e.g. `left = 0.5`
/// discards the left half). A text item is kept only when it lies
/// *entirely* inside the surviving rectangle
/// `[left·W, (1-right)·W] × [top·H, (1-bottom)·H]` (top-left origin).
/// `None` (default) keeps the whole page. Applied after OCR merge, so it
/// also drops OCR-sourced text outside the region.
pub crop_box: Option<CropBox>,
/// Drop diagonal (skewed) text — items whose rotation is more than 2°
/// off the nearest right angle (0/90/180/270). Default `false`.
pub skip_diagonal_text: bool,
/// Compute per-page complexity signals during `parse` and attach them to
/// each `ParsedPage` (surfaced as a `complexity` object per page in JSON).
/// These are the same signals the standalone `is_complex` API returns.
/// Default `false`.
pub include_complexity: bool,
/// Expose page-scoped vector path data (`shapes` and merged horizontal /
/// vertical `lines`) in parse results. Default `false`; path objects are
/// still inspected internally for layout detection when disabled.
pub extract_vector_graphics: bool,
}
/// A page sub-region expressed as the fraction cropped from each side.
/// All fields are in `[0, 1]`; `left + right < 1` and `top + bottom < 1`
/// for a non-empty region. Origin is top-left, matching `TextItem` coordinates.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct CropBox {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
/// Image handling for the markdown emitter.
///
/// * `Off` — strip image references entirely.
/// * `Placeholder` (default) — emit `` references in
/// reading order at each image's y position, but do **not** extract or
/// return pixel bytes. Keeps response size small while letting the LLM see
/// where figures live in the document.
/// * `Embed` — emit the same references as `Placeholder`, and extract the
/// embedded pixel bytes into `ParseResult.images` (equivalent to setting
/// `LiteParseConfig::extract_images`).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ImageMode {
Off,
Placeholder,
Embed,
}
/// Supported output formats.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Json,
Text,
Markdown,
}
impl LiteParseConfig {
/// Whether embedded-image extraction should run. True when
/// `extract_images` is set, or when the legacy `ImageMode::Embed` is
/// configured (`Embed` implies byte extraction for backwards
/// compatibility).
pub fn effective_extract_images(&self) -> bool {
self.extract_images || self.image_mode == ImageMode::Embed
}
}
impl Default for LiteParseConfig {
fn default() -> Self {
Self {
ocr_language: "eng".to_string(),
// OCR is on by default only when a built-in engine is compiled in
// (the `tesseract` feature). Builds without a built-in engine
// (`tesseract` disabled, or WASM) default to OFF, so a consumer that
// just wants native text extraction works out of the box instead of
// hitting "OCR enabled but no engine available". OCR is still fully
// available in those builds by opting in explicitly: set
// `ocr_enabled = true` together with an `ocr_server_url` (or, on
// WASM, an `ocrEngine` callback). That explicit request still fails
// fast if no engine is reachable, so a real misconfiguration is
// never silently swallowed.
ocr_enabled: cfg!(feature = "tesseract"),
ocr_server_url: None,
ocr_server_headers: Vec::new(),
tessdata_path: None,
max_pages: 1000,
target_pages: None,
dpi: 150.0,
output_format: OutputFormat::Json,
preserve_very_small_text: false,
password: None,
quiet: false,
num_workers: default_num_workers(),
image_mode: ImageMode::Placeholder,
extract_images: false,
image_output_dir: None,
extract_links: true,
extract_annotations: false,
extract_form_fields: false,
extract_structure_tree: false,
extract_content_bounds: false,
extract_xfa_packets: false,
detect_screenshot_rects: false,
render_form_fields: false,
ocr_failure_fatal: true,
ocr_hedge_delays_ms: Vec::new(),
emit_word_boxes: false,
extract_text_metadata: false,
crop_box: None,
skip_diagonal_text: false,
include_complexity: false,
extract_vector_graphics: false,
}
}
}
/// Returns the default number of OCR workers: CPU cores - 1, minimum 1.
fn default_num_workers() -> usize {
std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1).max(1))
.unwrap_or(1)
}
/// Upper bound on the number of pages a `--target-pages` argument may expand
/// to. Ranges are materialised eagerly into a `Vec<u32>`, so without a cap a
/// tiny argument like `1-4294967295` would try to allocate ~17 GB and the
/// process would be OOM-killed before the document is even opened. No real
/// document approaches this many pages, so the cap only ever rejects nonsense
/// input.
const MAX_TARGET_PAGES: u64 = 100_000;
#[doc(hidden)]
pub fn parse_target_pages(s: &str) -> Result<Vec<u32>, String> {
let mut pages = Vec::new();
for part in s.split(',') {
let part = part.trim();
if part.contains('-') {
let bounds: Vec<&str> = part.splitn(2, '-').collect();
let start: u32 = bounds[0]
.trim()
.parse()
.map_err(|_| format!("invalid page number: {}", bounds[0]))?;
let end: u32 = bounds[1]
.trim()
.parse()
.map_err(|_| format!("invalid page number: {}", bounds[1]))?;
if start > end {
return Err(format!("invalid range: {}-{}", start, end));
}
// Reject before expanding so an oversized range can never allocate
// gigabytes. `end >= start`, so the span cannot underflow.
let span = end as u64 - start as u64 + 1;
if pages.len() as u64 + span > MAX_TARGET_PAGES {
return Err(format!(
"too many target pages: {}-{} exceeds the limit of {}",
start, end, MAX_TARGET_PAGES
));
}
for p in start..=end {
pages.push(p);
}
} else {
let p: u32 = part
.parse()
.map_err(|_| format!("invalid page number: {}", part))?;
if pages.len() as u64 + 1 > MAX_TARGET_PAGES {
return Err(format!(
"too many target pages: exceeds the limit of {}",
MAX_TARGET_PAGES
));
}
pages.push(p);
}
}
pages.sort();
pages.dedup();
Ok(pages)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_target_pages() {
assert_eq!(
parse_target_pages("1-5,10,15-20").unwrap(),
vec![1, 2, 3, 4, 5, 10, 15, 16, 17, 18, 19, 20]
);
assert_eq!(parse_target_pages("3").unwrap(), vec![3]);
assert_eq!(parse_target_pages("1,1,2").unwrap(), vec![1, 2]);
assert!(parse_target_pages("5-3").is_err());
assert!(parse_target_pages("abc").is_err());
}
#[test]
fn test_parse_target_pages_with_whitespace() {
assert_eq!(parse_target_pages(" 1 , 2 - 4 ").unwrap(), vec![1, 2, 3, 4]);
}
#[test]
fn test_parse_target_pages_single_range() {
assert_eq!(parse_target_pages("2-2").unwrap(), vec![2]);
}
#[test]
fn test_parse_target_pages_rejects_oversized_range() {
// The headline OOM repro from #269: a 12-character argument must not
// be allowed to expand into a multi-gigabyte allocation.
assert!(parse_target_pages("1-4294967295").is_err());
assert!(parse_target_pages("0-4294967295").is_err());
// The cap is on the total page count, so multiple ranges that
// together exceed the limit are rejected too.
assert!(parse_target_pages("1-60000,60001-120000").is_err());
// A selection within the limit is still parsed normally.
assert_eq!(parse_target_pages("1-1000").unwrap().len(), 1000);
}
#[test]
fn test_default_config() {
let c = LiteParseConfig::default();
assert_eq!(c.ocr_language, "eng");
// OCR defaults on only when a built-in engine is compiled in.
assert_eq!(c.ocr_enabled, cfg!(feature = "tesseract"));
assert_eq!(c.max_pages, 1000);
assert_eq!(c.dpi, 150.0);
assert_eq!(c.output_format, OutputFormat::Json);
assert!(!c.preserve_very_small_text);
assert!(!c.quiet);
assert!(!c.extract_text_metadata);
assert!(c.password.is_none());
assert!(!c.extract_images);
assert!(!c.extract_structure_tree);
}
#[test]
fn test_output_format_lowercase_serde() {
let s = serde_json::to_string(&OutputFormat::Json).unwrap();
assert_eq!(s, "\"json\"");
let back: OutputFormat = serde_json::from_str("\"text\"").unwrap();
assert_eq!(back, OutputFormat::Text);
}
#[test]
fn test_config_roundtrip() {
let c = LiteParseConfig::default();
let s = serde_json::to_string(&c).unwrap();
let back: LiteParseConfig = serde_json::from_str(&s).unwrap();
assert_eq!(back.ocr_language, c.ocr_language);
assert_eq!(back.output_format, c.output_format);
}
}