fix(optimize): page content and forms share one decode budget per page

Image measurement and fragment merging decoded every /Contents entry
and every form occurrence with a fresh 1 GiB allowance, so repeated
references to one compressed stream multiplied memory. Both charge a
per-page DecodeBudget. A page whose content does not decode within it
is left unmerged, and its images are not resampled, since a use may be
missed; fragment merging no longer rewrites a page from content it
could not fully decode.
This commit is contained in:
aiongg
2026-10-22 16:57:54 +00:00
parent 13d9c9bf8c
commit 0b6adb70da
2 changed files with 50 additions and 23 deletions
+4 -2
View File
@@ -18,7 +18,7 @@
//! drops them.
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample};
use crate::{OptimizeError, Report, Stage};
@@ -213,7 +213,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) ->
/// Merge the fragment runs of one page; returns how many fragments were merged.
fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<usize, OptimizeError> {
let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) };
let parsed = parse(&content_bytes(doc, &contents));
// A page whose content isn't all known is left alone: rewriting it would drop the rest.
let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) };
let parsed = parse(&data);
if parsed.skipped > 0 {
return Ok(0);
}
+46 -21
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage};
@@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
None
}
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
match &*doc.resolve(contents) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
Object::Array(a) => {
let pieces: Vec<_> = a
.iter()
.filter_map(|c| match &*doc.resolve(c) {
Object::Stream(s) => Some(s.decoded().unwrap_or_default()),
_ => None,
})
.collect();
Pieces::join(&pieces).data().to_vec()
}
_ => Vec::new(),
}
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
/// doesn't decode or the budget runs out: the content is not all known.
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
Some(Pieces::join(&pieces).data().to_vec())
}
/// Forms nested deeper than this aren't followed.
@@ -57,6 +47,8 @@ struct Walk<'a> {
/// Forms walked at least once.
seen: HashSet<ObjRef>,
rewalks: usize,
/// Decoded bytes left for the page being walked.
budget: DecodeBudget,
/// False once a use may have been missed (depth or work limit).
complete: bool,
out: HashMap<ObjRef, f64>,
@@ -135,23 +127,42 @@ impl Walk<'_> {
}
self.rewalks += 1;
}
let Ok(data) = self.budget.decode(s) else {
self.complete = false;
return;
};
self.path.push(r);
self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner);
self.content(&data, own.as_ref().unwrap_or(resources), inner);
self.path.pop();
}
}
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
///
/// If forms nest too deeply or are drawn too many times to measure every use, every image is
/// given `INFINITY` (never resampled), since its largest use may be among those missed.
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
/// among those missed.
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() };
let mut walk = Walk {
doc,
path: Vec::new(),
walked: HashSet::new(),
seen: HashSet::new(),
rewalks: 0,
budget: DecodeBudget::page(),
complete: true,
out: HashMap::new(),
};
for p in pages {
let Some(d) = doc.get(*p).as_dict().cloned() else { continue };
let Some(contents) = d.get(b"Contents") else { continue };
let res = page_resources(doc, *p).unwrap_or_default();
walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
walk.budget = DecodeBudget::page();
let Some(data) = content_bytes(doc, contents, &mut walk.budget) else {
walk.complete = false;
continue;
};
walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
}
if !walk.complete {
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
@@ -433,3 +444,17 @@ mod dimension_tests {
assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4]));
}
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream};
#[test]
fn repeated_content_references_share_one_decode_budget() {
let mut doc = Document::new_empty();
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let contents = Object::Array(vec![Object::Ref(stream); 300]);
let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page());
assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300));
}
}