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) }