diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 4b8f619b48..5df9ebeb7c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -12,14 +12,18 @@ use crate::EditError; use crate::text::content_streams; /// The identity of a soft mask (`/SMask`) in the PDF graphics state. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum MaskIdentity { - /// Indirect reference to a mask dictionary or stream. - Ref(u32, u16), - /// Indirect reference to the transparency group (`/G`) in a mask dictionary. - Group(u32, u16), - /// Explicit named mask or resource name. - Named(Vec), +#[derive(Clone, Debug)] +pub(crate) struct MaskIdentity { + // Share the owning ExtGState instead of copying an arbitrarily large inline mask on q/Q. + // Its complete SMask value retains S, TR, BC and every other mask parameter, not just G. + definition: Arc, + ctm: Matrix, +} + +impl PartialEq for MaskIdentity { + fn eq(&self, other: &Self) -> bool { + self.ctm == other.ctm && self.definition.as_dict().and_then(|d| d.get(b"SMask")) == other.definition.as_dict().and_then(|d| d.get(b"SMask")) + } } /// An image drawn on a page. @@ -664,9 +668,13 @@ impl EffectiveGState { || self.print_params != other.print_params } - /// Merge the `gs_name` ExtGState `dict`. An inline soft mask with no group of its own is - /// known by the name of the ExtGState that sets it. - pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, gs_name: &[u8]) { + #[cfg(test)] + pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, ctm: Matrix) { + self.merge_shared(&Arc::new(Object::Dict(dict.clone())), doc, ctm); + } + + fn merge_shared(&mut self, definition: &Arc, doc: &Document, ctm: Matrix) { + let Some(dict) = definition.as_dict() else { return }; for key in [b"TR".as_slice(), b"TR2", b"OP", b"op", b"OPM", b"BG", b"BG2", b"UCR", b"UCR2", b"HT"] { if let Some(value) = dict.get(key) { self.print_params.insert(key.to_vec(), std::rc::Rc::new(value.clone())); @@ -691,26 +699,8 @@ impl EffectiveGState { } } if let Some(raw_smask) = dict.get(b"SMask") { - let resolved = doc.resolve(raw_smask); - if let Some(name) = resolved.as_name() { - if name == b"None" { - self.smask = None; - } else { - self.smask = Some(MaskIdentity::Named(name.to_vec())); - } - } else if let Some(mask_dict) = resolved.as_dict() { - if let Some(gr) = mask_dict.get(b"G").and_then(|g| g.as_ref()) { - self.smask = Some(MaskIdentity::Group(gr.num, gr.generation)); - } else if let Some(r) = raw_smask.as_ref() { - self.smask = Some(MaskIdentity::Ref(r.num, r.generation)); - } else if let Some(name) = mask_dict.get(b"Name").map(|n| doc.resolve(n)).and_then(|n| n.as_name().map(|s| s.to_vec())) { - self.smask = Some(MaskIdentity::Named(name)); - } else { - self.smask = Some(MaskIdentity::Named(gs_name.to_vec())); - } - } else if let Some(r) = raw_smask.as_ref() { - self.smask = Some(MaskIdentity::Ref(r.num, r.generation)); - } + let disabled = raw_smask.as_name() == Some(b"None") || raw_smask.as_ref().is_some_and(|r| doc.get(r).as_name() == Some(b"None")); + self.smask = (!disabled).then(|| MaskIdentity { definition: Arc::clone(definition), ctm }); } } } @@ -725,6 +715,7 @@ struct ExtendedGStateTracker { stack: Vec<(EffectiveGState, ActiveGsOps)>, current_state: EffectiveGState, active_gs_ops: ActiveGsOps, + definitions: std::collections::HashMap, Arc>, } impl ExtendedGStateTracker { @@ -744,17 +735,22 @@ impl ExtendedGStateTracker { } } b"gs" => { - let valid_gs = op - .name(0) - .and_then(|name| ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()).map(|dict| (name, dict))); - if let Some((name, dict)) = valid_gs { - self.current_state.merge_dict(&dict, doc, name); + let valid_gs = op.name(0).and_then(|name| { + let definition = self.definitions.get(name).cloned().or_else(|| { + let definition = doc.resolve(ext_gstates.get(name)?); + definition.as_dict()?; + self.definitions.insert(name.to_vec(), Arc::clone(&definition)); + Some(definition) + })?; + Some((name, definition)) + }); + if let Some((name, definition)) = valid_gs { + self.current_state.merge_shared(&definition, doc, ctm); let mut next = (*self.active_gs_ops).clone(); if let Some(pos) = next.iter().position(|(k, _, _)| k.as_slice() == name) { - if let Some(entry) = next.get_mut(pos) { - *entry = (name.to_vec(), op.clone(), ctm); - } - } else if next.len() < MAX_ACTIVE_GS_OPS { + next.remove(pos); + } + if next.len() < MAX_ACTIVE_GS_OPS { next.push((name.to_vec(), op.clone(), ctm)); } self.active_gs_ops = std::rc::Rc::new(next); @@ -1378,11 +1374,11 @@ fn arrange_image( } let mut reconstructed_state = dest_effective_state.clone(); - for (op, _) in &source_gs_ops { + for (op, ctm) in &source_gs_ops { if let Some(name) = op.name(0) - && let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) + && let Some(definition) = tracker.definitions.get(name) { - reconstructed_state.merge_dict(&dict, doc, name); + reconstructed_state.merge_shared(definition, doc, *ctm); } } @@ -1980,4 +1976,18 @@ mod allocation_tests { } assert_eq!(tracker.current, saved, "changing shared operands must preserve the saved state"); } + + #[test] + fn review_soft_mask_identity_includes_establishment_matrix() { + let doc = Document::new_empty(); + let mask = Dict::from_iter([(b"S".to_vec(), Object::name("Alpha")), (b"G".to_vec(), Object::Ref(ObjRef::new(8, 0)))]); + let definition = Dict::from_iter([(b"SMask".to_vec(), Object::Dict(mask))]); + let states = Dict::from_iter([(b"A".to_vec(), Object::Dict(definition))]); + let mut tracker = ExtendedGStateTracker::default(); + let op = Op::new("gs", vec![Object::name("A")]); + tracker.step(&op, &states, &doc, Matrix::IDENTITY).unwrap(); + let before = tracker.current_state.clone(); + tracker.step(&op, &states, &doc, Matrix::translate(50.0, 0.0)).unwrap(); + assert!(before.visually_differs(&tracker.current_state), "the same mask established under a different CTM is a different effective mask"); + } } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2e4b8ebbde..72e7ec88b1 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9789,10 +9789,10 @@ fn different_inline_soft_masks_have_distinct_identities() { dict2.set(b"SMask".to_vec(), Object::Dict(mask2)); let mut state1 = images::EffectiveGState::default(); - state1.merge_dict(&dict1, &doc, b"GS1"); + state1.merge_dict(&dict1, &doc, pdfcraft_content::Matrix::IDENTITY); let mut state2 = images::EffectiveGState::default(); - state2.merge_dict(&dict2, &doc, b"GS2"); + state2.merge_dict(&dict2, &doc, pdfcraft_content::Matrix::IDENTITY); assert!(state1.visually_differs(&state2), "two different inline soft masks must visually differ"); } @@ -10169,3 +10169,89 @@ fn editor_crop_group_move_keeps_an_outer_author_clip_fixed() { assert!(String::from_utf8_lossy(&bytes).starts_with("0 0 220 300 re W n")); assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); } + +fn review_state_raster(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }).rgba.to_vec() +} + +fn review_shared_mask_page(states: &str, luminosity_b: bool, mask_paint: &str) -> Document { + let content = format!("q {states} q 100 0 0 100 20 20 cm /Im0 Do Q Q q 0 0 1 rg 200 200 5 5 re f Q"); + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let subtype = if luminosity_b { "Luminosity" } else { "Alpha" }; + let mask_b = format!("<< /Type /ExtGState /SMask << /S /{subtype} /G 8 0 R >> >>"); + let group = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /I true /CS /DeviceGray >> /Length {} >>\nstream\n{mask_paint}\nendstream", + mask_paint.len() + ); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> /ExtGState << /A 6 0 R /B 7 0 R >> >> >>", + &stream, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\0\0\0\nendstream", + "<< /Type /ExtGState /SMask << /S /Alpha /G 8 0 R >> >>", + &mask_b, + &group, + ]) +} + +#[test] +fn review_repeated_gs_keeps_alpha_vs_luminosity_mask_chronology() { + for states in ["/A gs", "/A gs /B gs", "/A gs /B gs /A gs"] { + let mut doc = review_shared_mask_page(states, true, "0.25 g 0 0 300 300 re f"); + let before = review_state_raster(&reopen(&doc)); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == p[1] && p[1] == p[2] && p[0] < 230), "the masked image is visible"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask application order must survive: {states}"); + } +} + +#[test] +fn review_repeated_gs_keeps_last_mask_establishment_ctm() { + for states in [ + "1 0 0 1 50 0 cm /A gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm /A gs", + ] { + let mut doc = review_shared_mask_page(states, false, "0.25 g 0 0 60 300 re f"); + let before = review_state_raster(&reopen(&doc)); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask CTM must survive: {states}"); + } +} + +#[test] +fn review_soft_masks_sharing_a_group_keep_distinct_configurations() { + let doc = Document::new_empty(); + let mask = |subtype: &str| { + Dict::from_iter([( + b"SMask".to_vec(), + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name(subtype)), (b"G".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))])), + )]) + }; + let mut alpha = images::EffectiveGState::default(); + let mut luminosity = images::EffectiveGState::default(); + alpha.merge_dict(&mask("Alpha"), &doc, pdfcraft_content::Matrix::IDENTITY); + luminosity.merge_dict(&mask("Luminosity"), &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(alpha.visually_differs(&luminosity), "a shared group does not make Alpha and Luminosity the same mask"); + for (key, value) in [ + (b"BC".to_vec(), Object::Array(vec![Object::Real(0.5)])), + ( + b"TR".to_vec(), + Object::Dict(Dict::from_iter([ + (b"FunctionType".to_vec(), Object::Int(2)), + (b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])), + (b"N".to_vec(), Object::Int(2)), + ])), + ), + ] { + let mut changed = mask("Luminosity"); + let Object::Dict(definition) = changed.get_mut(b"SMask").unwrap() else { panic!("mask dictionary") }; + definition.set(key, value); + let mut state = images::EffectiveGState::default(); + state.merge_dict(&changed, &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(state.visually_differs(&luminosity), "mask backdrop and transfer function belong to its definition"); + } +}