M2.6: pruning keeps what a page or form damaged partway may draw

Extracting, splitting and combining pages drops the `/XObject` entries no
page draws. A page or form damaged partway was read up to the damage as the
whole of it, so an image named only after the damage was dropped, though
the stream still names it and other readers may still read that far.

- `content_bytes` says whether a page's stream is damaged, and pruning keeps
  everything such a page could reach, as it does for content that doesn't
  decode. A form is read the same way.
- A page made a form XObject (stamps, watermarks, backgrounds from a PDF)
  still draws what decoded before the damage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-07 12:37:35 +01:00
1 parent 480fdb114a
commit 50d0fcc403
3 files changed
+61 -14

No files matched your search

+3 -2
View File
@@ -499,9 +499,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(
let bbox = rect(b"CropBox").or_else(|| rect(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360);
// Content: the page's streams, decoded and joined.
// Content: the page's streams, decoded and joined. Of a stream damaged partway, the copy draws
// what decoded before the damage.
let content = match own.get(b"Contents") {
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?,
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?.0,
None => Vec::new(),
};
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
+17 -12
View File
@@ -219,10 +219,10 @@ impl Pruner<'_> {
}
#[cfg(test)]
crate::work(1);
match s.decoded() {
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown: keep everything it could reach.
Err(_) => {
match DecodeBudget::new(usize::MAX, 0).decode_with_damage(s, usize::MAX) {
Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown, past damage too: keep everything it could reach.
Ok((_, true)) | Err(_) => {
self.protect_type3_resources(&inner, inner_owner);
self.distrust(&inner, inner_owner);
}
@@ -232,16 +232,20 @@ impl Pruner<'_> {
}
/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`], and
/// whether a stream is damaged partway (its part is then what decoded before the damage). An
/// error when a stream can't be decoded or the budget runs out: the page may draw names that
/// can't be seen.
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<Vec<u8>, CosError> {
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<(Vec<u8>, bool), CosError> {
let mut out = Vec::new();
for data in DecodeBudget::page().decode_contents(doc, contents) {
out.extend(data?);
let mut damaged = false;
for read in DecodeBudget::page().decode_contents_with_damage(doc, contents) {
let (data, partial) = read?;
damaged |= partial;
out.extend(data);
out.push(b' ');
}
Ok(out)
Ok((out, damaged))
}
/// The resources of a page (as `walk` reports them) with their owner.
@@ -324,9 +328,10 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
let Some(pd) = pruner.doc.get(*page).as_dict().cloned() else { continue };
let Some(contents) = pd.get(b"Contents").cloned() else { continue };
match content_bytes(pruner.doc, &contents) {
Ok(data) if data.is_empty() => {}
Ok(data) => pruner.content(&data, &res_dict, owner, 0),
Err(_) => {
Ok((data, false)) if data.is_empty() => {}
Ok((data, false)) => pruner.content(&data, &res_dict, owner, 0),
// What the page draws past damage is unknown too.
Ok((_, true)) | Err(_) => {
pruner.protect_type3_resources(&res_dict, owner);
pruner.distrust(&res_dict, owner);
}
+41
View File
@@ -2628,6 +2628,47 @@ fn extract_keeps_images_an_undecodable_page_might_draw() {
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "an image the unreadable page may draw must stay");
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A page or form damaged partway may draw images past the damage, which other readers may still
/// read: pruning keeps them, as it does for content that doesn't decode.
#[test]
fn extract_keeps_images_content_damaged_partway_might_draw() {
use pdfcraft_cos::ObjRef;
let mut page = doc_images_with(&raw("/ImA Do"), "");
page.set(ObjRef::new(6, 0), damaged_flate(b"/ImA Do\n", b"/ImB Do"));
let mut form = doc_images_with(&raw("/Fm Do"), "");
let mut fm = damaged_flate(b"/ImA Do\n", b"/ImB Do");
fm.dict.set(b"Subtype".to_vec(), Object::name("Form"));
fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let fm = form.add(fm);
let xobjects: Dict =
[(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))]
.into_iter()
.collect();
form.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
for (src, what) in [(page, "page"), (form, "form")] {
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: an image drawn past the damage must stay: {names:?}");
}
}
#[test]
fn extract_keeps_images_type3_glyphs_may_draw() {
// A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`.