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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-31 08:03:39 +08:00
1 parent 363c76db87
commit 30a285ae67
2 files changed
+155

No files matched your search

+118
View File
@@ -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.
+37
View File
@@ -122,6 +122,13 @@ pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translu
let renamed = |cat: &[u8], name: &[u8]| -> Option<Vec<u8>> {
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"));