fix(edit): preserve graphics state chronology and mask identity

Replay repeated ExtGState names in last-application order. Compare each complete soft-mask value and its establishment matrix instead of only its group reference, and retain cached shared definitions across saved states. Saved-PDF raster regressions cover Alpha versus Luminosity over the same group, repeated aliases at different matrices, and ordinary sequence/single-mask controls. Definition checks also cover backdrop and transfer parameters. Actual baseline and reverted-comparator tests fail; scoped Linux native, wasm, clippy and formatting checks pass.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
FredApps
2026-11-01 17:41:12 +01:00
co-authored by Codex GPT-6.1
parent fdb6a6f33e
commit 8669aa48aa
2 changed files with 141 additions and 45 deletions
+53 -43
View File
@@ -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<u8>),
#[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<Object>,
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<Object>, 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<Vec<u8>, Arc<Object>>,
}
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");
}
}
+88 -2
View File
@@ -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<u8> {
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");
}
}