pdf inspection: count pending text clips and drop page content after reading (#566)

* pdf inspection: count pending text clips and drop page content after reading

- hayro-interpret: state_bytes counts the pending text-clip outlines a
  saved state copies, so MAX_SAVED_STATE_BYTES bounds them too.
- plate-test structure: read each page through a ContentReader into a
  buffer dropped after the page, instead of page.operations(), which keeps
  every page's decoded content for as long as the document.

* address review: cap page content read at the 256 MiB decode limit

* fix the saved-state test: start the clip outline with a MoveTo
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 16:12:06 -05:00
1 parent 32589246be
commit 7f0f352d21
2 files changed
+28 -3

No files matched your search

+8 -2
View File
@@ -69,6 +69,9 @@ pub struct ExtGState {
pub smask_tr: bool,
}
/// The most decoded content bytes read per page (the former `page.operations()` decode limit).
const MAX_PAGE_CONTENT: usize = 256 * 1024 * 1024;
/// Summarize `pdf`. Malformed objects are skipped rather than reported.
pub fn summarize(pdf: &[u8]) -> Result<Summary, Error> {
let pdf = Pdf::new(Arc::new(pdf.to_vec())).map_err(|e| Error::Read(format!("{e:?}")))?;
@@ -85,8 +88,11 @@ pub fn summarize(pdf: &[u8]) -> Result<Summary, Error> {
if let Some(group) = raw.get::<Dict<'_>>(b"Group".as_slice()) {
walk.group(&format!("page {}", index + 1), &group);
}
let mut ops = page.operations();
walk.operators(&mut ops);
// Read and dropped page by page: `page.operations()` keeps every page's decoded content
// for as long as the document, so pages sharing one stream would each hold a copy.
let mut content = Vec::new();
page.content_reader().read_into(&mut content, MAX_PAGE_CONTENT);
walk.operators(&mut UntypedIter::new(&content));
let res = page.resources();
walk.resources(&Resources {
ext_g_states: res.ext_g_states.clone(),
+20 -1
View File
@@ -193,7 +193,8 @@ const MAX_SAVED_STATE_BYTES: usize = 64 << 20;
/// Roughly what a saved copy of `state` holds.
/// PdfCraft patch (51): the sums saturate, so a huge dash array or clip list counts as over the
/// bound rather than wrapping on a 32-bit target.
/// bound rather than wrapping on a 32-bit target. The pending text-clip outlines (text render
/// modes 4–7) are copied with the state too, so they count.
fn state_bytes(state: &State<'_>) -> usize {
size_of::<State<'_>>()
.saturating_add(
@@ -205,6 +206,7 @@ fn state_bytes(state: &State<'_>) -> usize {
.saturating_mul(size_of::<f32>()),
)
.saturating_add(state.clips.len().saturating_mul(size_of::<ClipType>()))
.saturating_add(state.text_state.clip_paths.elements().len().saturating_mul(size_of::<PathEl>()))
}
impl<'a> Context<'a> {
@@ -690,6 +692,23 @@ mod saved_state_tests {
assert_eq!(ctx.saved_bytes, 0);
}
#[test]
fn pending_text_clip_outlines_count_toward_the_saved_bytes() {
let pdf = pdf();
let cache = InterpreterCache::new();
let mut ctx = Context::new(Affine::IDENTITY, Rect::new(0.0, 0.0, 100.0, 100.0), &cache, pdf.xref(), InterpreterSettings::default());
let empty = state_bytes(ctx.get());
ctx.get_mut().text_state.clip_paths.move_to((0.0, 0.0));
for i in 0..100_000 {
ctx.get_mut().text_state.clip_paths.line_to((f64::from(i), 1.0));
}
assert!(state_bytes(ctx.get()) >= empty + 100_000 * size_of::<PathEl>());
for _ in 0..20_000 {
ctx.save_state();
}
assert!(ctx.saved_bytes <= MAX_SAVED_STATE_BYTES);
}
#[test]
fn an_overlong_dash_array_strokes_solid() {
let max = crate::types::MAX_DASH_ENTRIES;