From 30a285ae671bb1731db9c7a0e9a08ef5d65a0bcb Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:42:53 +0800 Subject: [PATCH] M7: ungroup refuses a nested form, Type 3 font or tiling pattern that inherits renamed resources A Form XObject, Type 3 font or tiling pattern without /Resources of its own is drawn by the renderer with the resources of whatever draws it (vendor/hayro-interpret x_object.rs, font/type3.rs, pattern.rs chain them with Resources::from_parent). Ungroup moves the form's resources into the page's under new names, so such an item drawn from the page afterwards would find the page's resources instead: another font, or none. Ungroup now refuses when the merge renamed anything and the form's content draws such an item. A form without resources of its own is unaffected (it already uses the page's), and so are images and shading patterns. Raised by @Lcoding15 in #3067 (from #3222). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ --- crates/edit/src/tests.rs | 118 +++++++++++++++++++++++++++++++++++++ crates/edit/src/ungroup.rs | 37 ++++++++++++ 2 files changed, 155 insertions(+) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index eda4739ead..d7e8461b02 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9069,6 +9069,124 @@ fn ungroup_art(content: &[u8], group: bool, build: impl FnOnce(&mut Document) -> images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).map(|_| ()) } +/// A nested form, Type 3 font or tiling pattern without `/Resources` takes its names from the +/// form drawing it (the renderer chains them). Once the form's names are renamed into the page's, +/// it would find the page's instead (the page's Courier `F1`, not the form's Times), so ungroup +/// refuses. With resources of its own it is unaffected, and so is a form without resources of +/// its own, whose names are already the page's. +#[test] +fn ungroup_refuses_nested_content_that_inherits_renamed_resources() { + let times = || { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + d.set(b"Subtype".to_vec(), Object::name("Type1")); + d.set(b"BaseFont".to_vec(), Object::name("Times-Roman")); + Object::Dict(d) + }; + let nested = |doc: &mut Document, kind: &str, own: bool| -> (&'static str, Object) { + let mut d = Dict::new(); + if own { + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + } + match kind { + "form" => { + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + ("XObject", Object::Ref(doc.add(Object::Stream(Stream::flate(d, b"BT /F1 9 Tf (n) Tj ET"))))) + } + "type3" => { + d.set(b"Type".to_vec(), Object::name("Font")); + d.set(b"Subtype".to_vec(), Object::name("Type3")); + ("Font", Object::Dict(d)) + } + _ => { + d.set(b"PatternType".to_vec(), Object::Int(1)); + ("Pattern", Object::Ref(doc.add(Object::Stream(Stream::flate(d, b"BT /F1 9 Tf (n) Tj ET"))))) + } + } + }; + let content = |kind: &str| -> &'static [u8] { + match kind { + "form" => b"BT /F1 9 Tf (a) Tj ET /N Do", + "type3" => b"BT /F1 9 Tf (a) Tj /N 9 Tf (b) Tj ET", + // An uncoloured pattern: the name comes after the colour. + _ => b"BT /F1 9 Tf (a) Tj ET [/Pattern /DeviceGray] cs 0.5 /N scn 0 0 10 10 re f", + } + }; + let run = |kind: &'static str, own: bool| { + ungroup_art(content(kind), false, |doc| { + let (cat, obj) = nested(doc, kind, own); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), times()); + let mut res = Dict::new(); + if cat == "Font" { + fonts.set(b"N".to_vec(), obj); + } else { + let mut d = Dict::new(); + d.set(b"N".to_vec(), obj); + res.set(cat.as_bytes().to_vec(), Object::Dict(d)); + } + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res + }) + }; + for kind in ["form", "type3", "pattern"] { + let r = run(kind, false); + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("uses its resources")), "{kind}: {r:?}"); + assert!(run(kind, true).is_ok(), "{kind} with resources of its own"); + } + + // An image and a shading pattern have no content to look names up for. + let r = ungroup_art(b"BT /F1 9 Tf (a) Tj ET /Im Do /Pattern cs /Sh scn 0 0 10 10 re f", false, |doc| { + let mut img = Dict::new(); + img.set(b"Subtype".to_vec(), Object::name("Image")); + img.set(b"Width".to_vec(), Object::Int(1)); + img.set(b"Height".to_vec(), Object::Int(1)); + img.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + img.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let img = doc.add(Object::Stream(Stream::flate(img, b"@"))); + let mut shading = Dict::new(); + shading.set(b"ShadingType".to_vec(), Object::Int(2)); + let mut pattern = Dict::new(); + pattern.set(b"PatternType".to_vec(), Object::Int(2)); + pattern.set(b"Shading".to_vec(), Object::Dict(shading)); + let mut res = Dict::new(); + let mut d = Dict::new(); + d.set(b"Im".to_vec(), Object::Ref(img)); + res.set(b"XObject".to_vec(), Object::Dict(d)); + let mut d = Dict::new(); + d.set(b"Sh".to_vec(), Object::Dict(pattern)); + res.set(b"Pattern".to_vec(), Object::Dict(d)); + let mut d = Dict::new(); + d.set(b"F1".to_vec(), times()); + res.set(b"Font".to_vec(), Object::Dict(d)); + res + }); + assert!(r.is_ok(), "an image and a shading pattern: {r:?}"); + + // The outer form has no resources: the nested form already finds the page's, as it will. + let mut doc = fixture(); + let mut inner = Dict::new(); + inner.set(b"Subtype".to_vec(), Object::name("Form")); + inner.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + let inner = doc.add(Object::Stream(Stream::flate(inner, b"BT /F1 9 Tf (n) Tj ET"))); + let mut outer = Dict::new(); + outer.set(b"Subtype".to_vec(), Object::name("Form")); + outer.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + let outer = doc.add(Object::Stream(Stream::flate(outer, b"/N Do"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(outer)); + xo.set(b"N".to_vec(), Object::Ref(inner)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + let art = images::page_images(&doc, 0).unwrap().iter().position(|i| i.name == "Art").unwrap(); + images::change_image(&mut doc, 0, art, &images::ImageChange::Ungroup).unwrap(); +} + /// #3067 review: a form whose marked content or text objects don't balance within its own /// stream is refused: inlined, an extra EMC would close what the page opened, and one left open /// would take in the page's later content. diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index 31bf3a492a..8490056acb 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -122,6 +122,13 @@ pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translu let renamed = |cat: &[u8], name: &[u8]| -> Option> { renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone()) }; + // A nested form, Type 3 font or tiling pattern without resources of its own looks its names + // up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it + // would find the page's, which differ wherever a name was renamed (every form resource that + // isn't already the page's, the nested item's own entry included). + if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { + return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources")); + } let mut depth = 0usize; // Marked content and text objects must balance within the form's own stream: an extra EMC // would close one the page opened, and one left open would take in the page's later content. @@ -248,6 +255,36 @@ struct BlendWalk { /// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't /// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather /// than guesses. +/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from +/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. +fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { + let (cat, i): (&[u8], usize) = match op.op.as_slice() { + b"Do" => (b"XObject", 0), + b"Tf" => (b"Font", 0), + b"scn" | b"SCN" => match op.operands.len().checked_sub(1) { + Some(i) => (b"Pattern", i), + None => return false, + }, + _ => return false, + }; + let Some(Object::Name(name)) = op.operands.get(i) else { return false }; + let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else { + return false; + }; + let dict = match &*doc.resolve(&entry) { + Object::Stream(s) => s.dict.clone(), + Object::Dict(d) => d.clone(), + _ => return false, + }; + let drawn = match cat { + b"XObject" => dict.name(b"Subtype") == Some(b"Form"), + b"Font" => dict.name(b"Subtype") == Some(b"Type3"), + // A shading pattern has no content of its own. + _ => dict.int(b"PatternType") == Some(1), + }; + drawn && dict.get(b"Resources").is_none() +} + fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool { let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));