fix: skip page extraction when operation count exceeds 1M (#12)

* fix: skip page extraction when operation count exceeds 1M

Vector-heavy architectural PDFs can have 10-26M path operators per page,
causing ~60 GB allocation during per-op processing. The decode itself is
tolerable but the subsequent loop amplifies memory 2-5x with text items,
rects, paths, and state tracking.

Check operation count after Content::decode() and return empty extraction
for pages exceeding the limit, with a warning log.

* test: add unit test for excessive operations guard

Constructs a synthetic PDF with 1.1M path operators to verify
that pages exceeding the operation limit return empty extraction.
This commit is contained in:
Roman Yurchak
2026-03-23 17:37:36 -07:00
committed by GitHub
parent 8c4181434f
commit fa94314760
+52
View File
@@ -115,6 +115,17 @@ pub(crate) fn extract_page_text_items(
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));
}
// 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
@@ -1122,4 +1133,45 @@ mod tests {
dedup_rects(&mut single);
assert_eq!(single.len(), 1);
}
#[test]
fn test_skip_excessive_operations() {
use crate::tounicode::FontCMaps;
use lopdf::{dictionary, Object, Stream};
let mut doc = lopdf::Document::new();
// "0 0 m\n" = 6 bytes per op, 1_100_000 ops → ~6.6 MB content stream
let ops_bytes = "0 0 m\n".repeat(1_100_000).into_bytes();
let stream = Stream::new(dictionary! {}, ops_bytes);
let content_id = doc.add_object(Object::Stream(stream));
let page_dict = dictionary! {
"Type" => "Page",
"Contents" => Object::Reference(content_id),
"Resources" => dictionary! {},
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
};
let page_id = doc.add_object(page_dict);
// Register the page so get_page_content can find it
let pages_dict = dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(1),
"Kids" => vec![Object::Reference(page_id)],
};
let pages_id = doc.add_object(pages_dict);
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
doc.add_object(catalog);
let font_cmaps = FontCMaps::from_doc(&doc);
let result = extract_page_text_items(&doc, page_id, 1, &font_cmaps, false).unwrap();
let ((items, rects, lines), _has_gid) = result;
assert!(items.is_empty());
assert!(rects.is_empty());
assert!(lines.is_empty());
}
}