From 90b88ddcb658fc383a99640949454f3e5331795e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:44:55 +0700 Subject: [PATCH] Ungroup reads the Do it replaces and refuses one inside text or a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ungroup and text preparation now check the placement in one place, ungroup_ops, which is given the page's operators and the Do's index: - The form is looked up by the name the Do writes, byte for byte. Ungroup looked it up by the picture's display name, a lossy UTF-8 copy, so a form named /F#FF was 'missing'. - A Do with more than one operand is refused: the picture was read for its first, the renderer draws its last. - A Do inside BT…ET or an unfinished path is refused. Inline, the form's text objects would be nested in the page's, and its clip would take in the page's path. Preparation already refused these; Ungroup didn't. --- crates/edit/src/images.rs | 4 +--- crates/edit/src/tests.rs | 24 +++++++++++++++++++ crates/edit/src/text/forms.rs | 29 ++++------------------- crates/edit/src/ungroup.rs | 43 +++++++++++++++++++++++++---------- 4 files changed, 60 insertions(+), 40 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index e8debc1e46..8ac77c9deb 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1668,13 +1668,11 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into())); } let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op); let space = crate::ungroup::page_blend_space(doc, &p.dict); // One allowance for the form and everything it draws. let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0); let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } = - crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix), &mut budget) - .map_err(|r| r.error)?; + crate::ungroup::ungroup_ops(doc, &res, &ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget).map_err(|r| r.error)?; if !patterns.is_empty() { let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); for (name, copy) in patterns { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a10ace0ae1..a309d962ab 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -12944,6 +12944,30 @@ fn a_group_refused_for_renaming_is_prepared_once_the_page_has_its_names() { assert!(["alpha", "beta"].iter().all(|t| blocks.iter().any(|b| b.text == *t)), "{blocks:?}"); } +/// Ungroup finds the form by the name its `Do` writes, byte for byte: `/F#FF` is not UTF-8, and +/// the picture's display name (`F�`) names no resource. +#[test] +fn ungroup_finds_a_form_under_a_non_utf8_name() { + let mut doc = page_with_forms("/F#FF Do", &[("F#FF".into(), "BT /F1 14 Tf 40 500 Td (named) Tj ET".into())]); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "named")); +} + +/// Ungroup refuses a form drawn inside a text object or an unfinished path: inline, its text +/// objects would be nested in the page's, and its clip would take in the page's path (which its +/// `n` then discards). The same form drawn outside them is ungrouped. +#[test] +fn ungroup_refuses_a_form_drawn_inside_text_or_a_path() { + let form = [("Body".to_string(), "BT /F1 14 Tf 40 500 Td (body) Tj ET".to_string())]; + for content in ["BT /F1 14 Tf /Body Do ET", "10 10 m 20 20 l /Body Do S"] { + let mut doc = page_with_forms(content, &form); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("unfinished path or text object")), "{content}: {r:?}"); + } + let mut doc = page_with_forms("BT /F1 14 Tf ET /Body Do 10 10 m 20 20 l S", &form); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); +} + /// The `Do` is matched to its resource by the name's bytes as written, which need not be UTF-8. #[test] fn grouped_text_under_a_non_utf8_resource_name_is_prepared() { diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index 744b7e8df5..52703d0c44 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -77,24 +77,12 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result pending_path = true, - b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => pending_path = false, - b"BT" => in_text = true, - b"ET" => in_text = false, - _ => {} - } let Some(image) = images.get(&at) else { continue }; if image.object.is_some_and(|o| settled.contains(&o)) { continue; } - // The resource name exactly as the `Do` writes it (its last operand, the one drawn): - // a name need not be UTF-8, and `image.name` is only its display form. - let name = op.last_name().map_or_else(|| image.name.as_bytes().to_vec(), <[u8]>::to_vec); // An earlier placement is read again only when it was refused for a rename that the // names merged since have made unnecessary. The last pass opens nothing, so there it // keeps its reason. @@ -102,7 +90,9 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result 64 { return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); } } - let translucent = ungroup::translucent_at(&next, &merged, &ops, at); - match ungroup::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { + match ungroup::ungroup_ops(&next, &merged, &ops, at, space.as_ref(), Matrix(image.matrix), &mut decode) { Ok(ungroup::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => { // Vector and image artwork needs no preparation. Nested text groups are // visited on the next pass, with their inherited placement and clip in force. diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index ed7ba908a7..f0e7dc0ebe 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -89,16 +89,16 @@ pub(crate) struct Refusal { pub(crate) renaming: bool, } -/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state -/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a -/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). -/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, -/// then the CTM there). The form, and the forms and patterns it draws, decode within `budget`. +/// Ungroup the form drawn by the `Do` at `page_ops[at]` (the page's streams parsed as one, the +/// indexes `page_images` gives) from the page's `resources`. `page_space` is the page group's +/// blending colour space ([`page_blend_space`]). `placement` maps the form's space to the page's +/// default space at the `Do` (its `/Matrix`, then the CTM there). The form, and the forms and +/// patterns it draws, decode within `budget`. pub(crate) fn ungroup_ops( doc: &Document, resources: &Dict, - name: &[u8], - translucent: bool, + page_ops: &[Op], + at: usize, page_space: Option<&Object>, placement: Matrix, budget: &mut DecodeBudget, @@ -112,6 +112,19 @@ pub(crate) fn ungroup_ops( let refuse = |why: &str| refusal(why, false); // The form itself is the reason: every placement of it is refused the same way. let invalid = |why: &str| refusal(why, true); + let op = page_ops.get(at).filter(|op| op.is("Do")).ok_or_else(|| refuse("it is missing"))?; + // The renderer draws a `Do`'s last operand, and the placement was read for its first + // (`page_images`): with more than one they can be different forms. + if op.operands.len() != 1 { + return Err(refuse("it is drawn with an operand too many")); + } + let name = op.name(0).ok_or_else(|| refuse("it is missing"))?; + let (translucent, open) = state_at(doc, resources, page_ops, at); + // Inline, its clip would take in the page's unfinished path and its text objects would be + // nested in the page's. + if open { + return Err(refuse("it is drawn inside an unfinished path or text object")); + } let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; @@ -680,16 +693,22 @@ fn blends(doc: &Document, gs: &Dict) -> bool { !first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible"))) } -/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's -/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs` -/// changes only the parameters it sets. -pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool { +/// The state at op `at` of the page's streams parsed as one: whether the graphics state is +/// translucent, blended or soft-masked there, following `q`/`Q` and `gs` (each `gs` changes only +/// the parameters it sets), which a transparency group composites as a whole; and whether a path +/// or a text object is open there. +fn state_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> (bool, bool) { let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); // Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal. let mut stack: Vec<[bool; 4]> = Vec::new(); let mut now = [false; 4]; + let (mut path, mut text) = (false, false); for op in ops.iter().take(at) { match op.op.as_slice() { + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => path = true, + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => path = false, + b"BT" => text = true, + b"ET" => text = false, b"q" => stack.push(now), // A Q with nothing to restore is ignored, as renderers ignore it. b"Q" => now = stack.pop().unwrap_or(now), @@ -715,5 +734,5 @@ pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: u _ => {} } } - now.iter().any(|v| *v) + (now.iter().any(|v| *v), path || text) }