From 9792328d67fcbf781409b65ab5ca2f3357bdd324 Mon Sep 17 00:00:00 2001 From: Ozt-y Date: Mon, 9 Nov 2026 07:44:10 +0530 Subject: [PATCH] fix(edit): preserve annotation optional-content membership in marked content (#3213) (#3275) --- crates/edit/src/flatten.rs | 229 ++++++++- crates/edit/src/tests.rs | 77 +++ crates/edit/tests/flatten_optional_content.rs | 472 ++++++++++++++++++ 3 files changed, 759 insertions(+), 19 deletions(-) create mode 100644 crates/edit/tests/flatten_optional_content.rs diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index c54b062af6..73c5389c25 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -136,6 +136,160 @@ fn xobject_name(existing: &Dict, pending: &Dict, n: &mut u32) -> String { } } +/// Copy usable property resources, including inherited resources supplied by the page model. +/// Invalid data must stay intact rather than being replaced while transferring membership. +fn properties(doc: &Document, page: &Dict) -> Option { + let Some(resources) = page.get(b"Resources") else { return Some(Dict::new()) }; + let resources = doc.resolve(resources); + let Object::Dict(resources) = &*resources else { return None }; + let Some(properties) = resources.get(b"Properties") else { return Some(Dict::new()) }; + match &*doc.resolve(properties) { + Object::Dict(properties) => Some(properties.clone()), + _ => None, + } +} + +/// Validate an ISO 32000-1 §8.11.2.2 `/VE` visibility expression within recursion depth 16 +/// and a shared node-visit `budget`. +fn valid_ve(doc: &Document, expr: &Object, depth: usize, budget: &mut usize) -> bool { + if depth > 16 || *budget == 0 { + return false; + } + *budget -= 1; + let resolved = doc.resolve(expr); + if let Some(arr) = resolved.as_array() { + let [op, rest @ ..] = arr.as_slice() else { return false }; + let op_obj = doc.resolve(op); + let Some(name) = op_obj.as_name() else { return false }; + match (name, rest) { + (b"Not", [operand]) => valid_ve(doc, operand, depth + 1, budget), + (b"And" | b"Or", [_, ..]) => rest.iter().all(|o| valid_ve(doc, o, depth + 1, budget)), + _ => false, + } + } else if depth > 0 { + expr.as_ref().is_some_and(|r| matches!(&*doc.get(r), Object::Dict(d) if d.name(b"Type") == Some(b"OCG"))) + } else { + false + } +} + +/// Validate an `/OCMD` dictionary's `/P` policy and `/VE` visibility expression, if present. +fn valid_ocmd(doc: &Document, d: &Dict) -> bool { + if let Some(p) = d.get(b"P") + && !matches!(doc.resolve(p).as_name(), Some(b"AllOn" | b"AnyOn" | b"AnyOff" | b"AllOff")) + { + return false; + } + if let Some(ve) = d.get(b"VE") + && !valid_ve(doc, ve, 0, &mut 4096) + { + return false; + } + true +} + +/// Preserve the stored value, not a reconstructed membership dictionary. A direct inline +/// dictionary lacks an indirect reference (ObjRef) to identify it in page resources, and an +/// OCMD with a malformed or overly deep `/VE` expression stays on the annotation. +fn transferable(doc: &Document, annotation: &Dict, usable_properties: bool) -> bool { + let Some(membership) = annotation.get(b"OC") else { return true }; + if !usable_properties { + return false; + } + match &*doc.resolve(membership) { + Object::Dict(d) => match d.name(b"Type") { + Some(b"OCG") => membership.as_ref().is_some(), + Some(b"OCMD") => valid_ocmd(doc, d), + _ => false, + }, + _ => false, + } +} + +#[derive(Default)] +struct Retained { + refs: HashSet, + inline: HashSet, +} + +impl Retained { + fn contains(&self, index: usize, entry: &Object) -> bool { + self.inline.contains(&index) || entry.as_ref().is_some_and(|r| self.refs.contains(&r)) + } + + fn insert(&mut self, index: usize, entry: &Object) { + if let Some(r) = entry.as_ref() { + self.refs.insert(r); + } else { + self.inline.insert(index); + } + } +} + +/// Retained annotations keep their replies and pop-ups, including transitive reply chains. +/// +/// Retention is strictly downward (parent -> child/reply/popup). A non-transferable child does not +/// keep its transferable parent, matching the engine invariant for undrawn children (#1794). +fn retained_annotations(doc: &Document, list: &[Object], usable_properties: bool) -> Retained { + let mut children: HashMap> = HashMap::new(); + let mut inline_children: HashMap> = HashMap::new(); + let mut pending = Vec::new(); + let mut retained = Retained::default(); + for (k, entry) in list.iter().enumerate() { + let object = doc.resolve(entry); + let Some(annotation) = object.as_dict() else { continue }; + let not_transferable = !transferable(doc, annotation, usable_properties); + if let Some(reference) = entry.as_ref() { + if not_transferable { + pending.push(reference); + } + if let Some(parent) = annotation.reference(b"IRT") { + children.entry(parent).or_default().push(reference); + } + if annotation.name(b"Subtype") == Some(b"Popup") + && let Some(parent) = annotation.reference(b"Parent") + { + children.entry(parent).or_default().push(reference); + } + } else { + if not_transferable { + retained.inline.insert(k); + } + if let Some(parent) = annotation.reference(b"IRT") { + inline_children.entry(parent).or_default().push(k); + } + if annotation.name(b"Subtype") == Some(b"Popup") + && let Some(parent) = annotation.reference(b"Parent") + { + inline_children.entry(parent).or_default().push(k); + } + } + } + while let Some(current) = pending.pop() { + if !retained.refs.insert(current) { + continue; + } + if let Some(kids) = children.get(¤t) { + pending.extend(kids.iter().copied()); + } + if let Some(inline_kids) = inline_children.get(¤t) { + retained.inline.extend(inline_kids.iter().copied()); + } + } + retained +} + +/// A Properties resource name that is not already on the page or in `pending`. +fn property_name(existing: &Dict, pending: &Dict, n: &mut u64) -> Option { + loop { + let name = format!("PCFlOC{n}"); + *n = n.checked_add(1)?; + if !existing.contains(name.as_bytes()) && !pending.contains(name.as_bytes()) { + return Some(name); + } + } +} + /// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged /// into page content. pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result { @@ -157,10 +311,15 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result Option<(usize, bool, char)> { for &i in pages { let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - for entry in &list { + let usable_properties = properties(doc, &all[i].dict).is_some(); + let retained = retained_annotations(doc, &list, usable_properties); + for (k, entry) in list.iter().enumerate() { + if retained.contains(k, entry) { + continue; + } let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue }; let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 { + if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) { continue; } let widget = d.name(b"Subtype") == Some(b"Widget"); @@ -174,6 +333,26 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi None } +/// Ensure comments without stored appearances have appearances generated before flattening. +fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retained: &HashSet, usable_properties: bool) { + for entry in list { + let Some(r) = entry.as_ref() else { continue }; + if retained.contains(&r) { + continue; + } + let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default(); + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"Widget" || !selected(doc, &d, which) || !transferable(doc, &d, usable_properties) { + continue; + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + continue; + } + let _ = pdfcraft_annot::set_appearance(doc, r); + } +} + /// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on /// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result Result Result> /AP << /N 6 0 R >>", 1, false); } +/// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and +/// valid OCMDs, including ISO 32000-1 §8.11.2.2 `/VE` expressions within depth 16 and the +/// 4,096-node visit budget) in `/OC /PCFlOC0 BDC … EMC` and registers them in +/// `/Resources /Properties`, while retaining annotations with untransferable, malformed, +/// overly deep, or over-budget `/OC` definitions in `/Annots`. +#[test] +fn flattening_transfers_annotation_optional_content_to_marked_content() { + let page = stream("", " "); + let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; + let case = |extra: &str, drawn: usize, left: bool, bdc: bool| { + let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /AP << /N 6 0 R >> {extra} >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R 8 0 R 13 0 R] /D << /OFF [7 0 R] /ON [8 0 R 13 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>", + &annot, + &page, + form, + "<< /Type /OCG /Name (Hidden layer) >>", + "<< /Type /OCG /Name (Shown layer) >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOn >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOff >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOff >>", + "<< /Type /OCG /Name (Second shown layer) >>", + "[ /Or 15 0 R 15 0 R 15 0 R 15 0 R ]", + "[ /Or 16 0 R 16 0 R 16 0 R 16 0 R ]", + "[ /Or 17 0 R 17 0 R 17 0 R 17 0 R ]", + "[ /Or 18 0 R 18 0 R 18 0 R 18 0 R ]", + "[ /Or 8 0 R 8 0 R 8 0 R 8 0 R ]", + ]); + let before = streams(&doc, 0); + let expected_oc = doc.get(pdfcraft_cos::ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"OC").cloned()); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn, "{extra}"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(p.dict.contains(b"Annots"), left, "{extra}"); + if drawn == 0 { + assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the retained comment: {extra}"); + } else { + let flat = streams(&doc, 0).pop().unwrap(); + assert_eq!(flat.contains("/OC /PCFlOC0 BDC") && flat.contains("EMC"), bdc, "{extra}: {flat}"); + if bdc { + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let props = doc.resolve(res.as_dict().unwrap().get(b"Properties").unwrap()); + assert_eq!(props.as_dict().unwrap().get(b"PCFlOC0").cloned(), expected_oc, "{extra}"); + } + } + }; + case("/OC 7 0 R", 1, false, true); + case("/OC 8 0 R", 1, false, true); + case("/OC 9 0 R", 1, false, true); + case("/OC 10 0 R", 1, false, true); + case("/OC 11 0 R", 1, false, true); + case("/OC 12 0 R", 1, false, true); + case("", 1, false, false); + // Valid ISO 32000-1 §8.11.2.2 /VE visibility expressions are transferred with their OCMD. + case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /And 8 0 R 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Or 8 0 R 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Or 7 0 R [ /And 8 0 R 13 0 R ] ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /And 8 0 R [ /Not 13 0 R ] ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] /OCGs [8 0 R] /P /AllOn >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] /OCGs [7 0 R] /P /AllOn >>", 1, false, true); + // Malformed /VE, invalid /P, inline /OCG, and /VE exceeding depth 16 or the node budget fail closed and stay in /Annots. + case("/OC << /Type /OCG /Name (Inline layer) >>", 0, true, false); + case("/OC << /Type /OCMD /OCGs [8 0 R] /P /InvalidPolicy >>", 0, true, false); + case("/OC << /Type /OCMD /VE 8 0 R /OCGs [8 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [ /Not 7 0 R 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [ /Xor 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + let deep_ve = format!("{}8 0 R{}", "[ /Not ".repeat(17), " ]".repeat(17)); + case(&format!("/OC << /Type /OCMD /VE {deep_ve} /OCGs [8 0 R] /P /AllOn >>"), 0, true, false); + case("/OC << /Type /OCMD /VE [ /Or 14 0 R 14 0 R 14 0 R 14 0 R ] /OCGs [8 0 R] /P /AllOn >>", 0, true, false); +} + /// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must /// generate that appearance and bake it into the page, not delete a comment that shows an icon. #[test] diff --git a/crates/edit/tests/flatten_optional_content.rs b/crates/edit/tests/flatten_optional_content.rs new file mode 100644 index 0000000000..b6a6165149 --- /dev/null +++ b/crates/edit/tests/flatten_optional_content.rs @@ -0,0 +1,472 @@ +//! Optional-content regression fixtures for issue #3213. +use std::sync::Arc; + +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental}; + +/// Build a single-page PDF fixture with a hidden OCG (`4 0 R`), a shown OCG (`5 0 R`), +/// a form appearance (`7 0 R`), an annotation (`8 0 R`), and an OCMD (`9 0 R`). +fn fixture(membership: &str, flags: i64, appearance_membership: &str) -> Document { + let ink = "1 0 0 rg 0 0 100 50 re f"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [4 0 R] /ON [5 0 R] /Order [4 0 R 5 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Properties << /Existing 5 0 R /PCFlOC0 5 0 R /Private (keep) >> >> /Contents 6 0 R /Annots [8 0 R] >>".into(), + "<< /Type /OCG /Name (Hidden layer) /Private (keep layer) >>".into(), + "<< /Type /OCG /Name (Shown layer) >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] {appearance_membership} /Length {} >>\nstream\n{ink}\nendstream", ink.len()), + format!("<< /Type /Annot /Subtype /Square /Rect [50 50 150 100] /C [1 0 0] /AP << /N 7 0 R >> /F {flags} {membership} /Private (keep annotation) >>"), + "<< /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep membership) >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// Serialize `doc` using an incremental save. +fn saved(doc: &Document) -> Vec { + write_incremental(doc, &SaveOptions::default()).unwrap() +} + +/// Optionally export test PDF bytes when `PDFCRAFT_3213_ARTIFACTS` is set. +fn export(name: &str, stage: &str, bytes: &[u8]) { + if let Some(directory) = std::env::var_os("PDFCRAFT_3213_ARTIFACTS") { + std::fs::write(std::path::Path::new(&directory).join(format!("{name}-{stage}.pdf")), bytes).unwrap(); + } +} + +/// Verify that transferable optional-content memberships are bound to flattened appearances +/// after saving and reopening, while hidden-flag annotations remain untouched. +#[test] +fn flatten_keeps_annotation_optional_content_after_save_and_reopen() { + let mut lost = Vec::new(); + for (name, membership, flags, ap_oc) in [ + ("off", "/OC 4 0 R", 4, ""), + ("on", "/OC 5 0 R", 4, ""), + ("ocmd", "/OC 9 0 R", 4, ""), + ("independent_ap", "/OC 4 0 R", 4, "/OC 5 0 R"), + ("direct_ocmd", "/OC << /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep inline) >>", 4, ""), + ("ocmd_ve", "/OC << /Type /OCMD /VE [/And 4 0 R [/Not 5 0 R]] /Private (keep expression) >>", 4, ""), + ("visible", "", 4, ""), + ("hidden_flag", "", 2, ""), + ] { + let mut doc = fixture(membership, flags, ap_oc); + let original_appearance = (*doc.get(ObjRef::new(7, 0))).clone(); + let expected_membership = doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().unwrap().get(b"OC").cloned(); + export(name, "before", &saved(&doc)); + let count = pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(); + let bytes = saved(&doc); + export(name, "flat", &bytes); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(count, usize::from(flags != 2), "{name}"); + let paints = paint_memberships(&reopened); + if flags != 2 && paints != vec![(ObjRef::new(7, 0), expected_membership)] { + lost.push(name); + } + assert_eq!(*reopened.get(ObjRef::new(7, 0)), original_appearance, "{name}: preserve appearance"); + } + assert!(lost.is_empty(), "flattened paint loses its original membership: {lost:?}"); +} + +/// Verify that multiple annotations sharing one appearance stream keep separate memberships +/// without mutating the shared appearance stream itself. +#[test] +fn flatten_shared_appearance_keeps_separate_memberships_and_original_stream() { + let mut doc = fixture("/OC 4 0 R", 4, ""); + let first = ObjRef::new(8, 0); + let ap = ObjRef::new(7, 0); + let original_ap = (*doc.get(ap)).clone(); + let mut second = doc.get(first).as_dict().unwrap().clone(); + second.set(b"OC".to_vec(), Object::Ref(ObjRef::new(5, 0))); + second.set(b"Rect".to_vec(), Object::Array([50, 120, 150, 170].into_iter().map(Object::Int).collect())); + let second = doc.add(Object::Dict(second)); + let mut plain = doc.get(first).as_dict().unwrap().clone(); + plain.remove(b"OC"); + plain.set(b"Rect".to_vec(), Object::Array([0, 0, 20, 10].into_iter().map(Object::Int).collect())); + let plain = doc.add(Object::Dict(plain)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second), Object::Ref(plain)])) + }) + .unwrap(); + export("shared", "before", &saved(&doc)); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 3); + let bytes = saved(&doc); + export("shared", "flat", &bytes); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(*doc.get(ap), original_ap, "a shared appearance must never acquire either annotation's /OC"); + assert_eq!(paint_memberships(&doc), vec![(ap, Some(Object::Ref(ObjRef::new(4, 0)))), (ap, Some(Object::Ref(ObjRef::new(5, 0)))), (ap, None)]); +} + +/// Associate each `Do` invocation with its active `/OC` marked-content block. +fn paint_memberships(doc: &Document) -> Vec<(ObjRef, Option)> { + let page = pdfcraft_model::pages(doc).remove(0); + let resources = doc.resolve(page.dict.get(b"Resources").unwrap()); + let resources = resources.as_dict().unwrap(); + let properties = resources.get(b"Properties").map(|p| doc.resolve(p)); + let xobjects = resources.get(b"XObject").map(|p| doc.resolve(p)); + let content = doc.resolve(page.dict.get(b"Contents").unwrap()); + let streams = match &*content { + Object::Array(list) => list.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + }; + let mut contexts = Vec::new(); + let mut paints = Vec::new(); + for stream in streams { + let resolved = doc.resolve(&stream); + let Object::Stream(stream) = &*resolved else { panic!("content stream") }; + let parsed = pdfcraft_content::parse(&stream.decoded().unwrap()); + assert_eq!(parsed.skipped, 0); + for op in parsed.ops { + if op.is("BDC") { + let membership = if op.name(0) == Some(b"OC") { + Some(properties.as_ref().unwrap().as_dict().unwrap().get(op.name(1).unwrap()).unwrap().clone()) + } else { + None + }; + contexts.push(membership); + } else if op.is("BMC") { + contexts.push(None); + } else if op.is("EMC") { + assert!(contexts.pop().is_some(), "balanced marked content"); + } else if op.is("Do") { + let reference = xobjects.as_ref().unwrap().as_dict().unwrap().reference(op.name(0).unwrap()).unwrap(); + paints.push((reference, contexts.iter().rev().find_map(Clone::clone))); + } + } + } + assert!(contexts.is_empty(), "every optional-content block must close"); + paints +} + +/// Verify that unsupported optional-content memberships retain the annotation, its transitive +/// replies and pop-ups, and its AcroForm field links. +#[test] +fn unsupported_membership_keeps_annotations_replies_and_field_links() { + use pdfcraft_cos::Stream; + let direct_ocg = Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec())); + d + }); + let wrong_type = Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"Other".to_vec())); + d + }); + for membership in [Object::Null, Object::Int(7), Object::Ref(ObjRef::new(999, 0)), Object::Ref(ObjRef::new(7, 0)), direct_ocg, wrong_type] { + for widget in [false, true] { + let mut doc = fixture("", 4, ""); + let retained = ObjRef::new(8, 0); + let mut successful = doc.get(retained).as_dict().unwrap().clone(); + successful.remove(b"OC"); + let successful = doc.add(Object::Dict(successful)); + let empty_appearance = if widget { + let mut empty = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().clone(); + empty.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])); + let reference = doc.add(Object::Stream(Stream::from_raw(empty, Vec::new()))); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(reference)); + Some(Object::Dict(ap)) + } else { + None + }; + doc.update_dict(retained, |d| { + d.set(b"OC".to_vec(), membership.clone()); + d.set(b"IRT".to_vec(), Object::Ref(successful)); + if widget { + d.set(b"Subtype".to_vec(), Object::Name(b"Widget".to_vec())); + d.set(b"AP".to_vec(), empty_appearance.clone().unwrap()); + } else { + d.remove(b"AP"); + } + }) + .unwrap(); + let mut reply = doc.get(retained).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"Subtype".to_vec(), Object::Name(b"Square".to_vec())); + reply.set(b"IRT".to_vec(), Object::Ref(retained)); + reply.set(b"AP".to_vec(), doc.get(successful).as_dict().unwrap().get(b"AP").unwrap().clone()); + let reply = doc.add(Object::Dict(reply)); + let mut grandchild = doc.get(reply).as_dict().unwrap().clone(); + grandchild.set(b"IRT".to_vec(), Object::Ref(reply)); + let grandchild = doc.add(Object::Dict(grandchild)); + let popup = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + d.set(b"Parent".to_vec(), Object::Ref(retained)); + d + })); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set( + b"Annots".to_vec(), + Object::Array(vec![ + Object::Ref(successful), + Object::Ref(retained), + Object::Ref(reply), + Object::Ref(grandchild), + Object::Ref(popup), + ]), + ) + }) + .unwrap(); + if widget { + let fields = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(retained)])); + d + })); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"AcroForm".to_vec(), Object::Ref(fields))).unwrap(); + } + let originals = [retained, reply, grandchild, popup].map(|r| (*doc.get(r)).clone()); + let original_fields = doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, true).unwrap(), 1, "{membership:?}"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [retained, reply, grandchild, popup].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!( + doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), + &vec![Object::Ref(retained), Object::Ref(reply), Object::Ref(grandchild), Object::Ref(popup)] + ); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()), original_fields); + } + } + // A stream that claims Type OCG must not qualify through Object::as_dict(). + let mut doc = fixture("/OC 4 0 R", 4, ""); + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec())); + doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(d, Vec::new()))); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); + + // Shared-subexpression /VE fan-out (10^16 paths within depth 16) stops at the node budget + // and retains the annotation unchanged instead of hanging. + let mut doc = fixture("/OC 9 0 R", 4, ""); + let mut target = ObjRef::new(4, 0); + for _ in 0..16 { + let mut expr = vec![Object::Name(b"Or".to_vec())]; + expr.extend(std::iter::repeat_n(Object::Ref(target), 10)); + target = doc.add(Object::Array(expr)); + } + let ve = (*doc.get(target)).clone(); + doc.update_dict(ObjRef::new(9, 0), |d| { + d.set(b"VE".to_vec(), ve.clone()); + }) + .unwrap(); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); +} + +/// Preserve a malformed reply that targets a retained popup instead of a markup annotation. +#[test] +fn unsupported_membership_keeps_popup_replies_after_save_and_reopen() { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + let popup = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + d.set(b"Parent".to_vec(), Object::Ref(root)); + d.set(b"Private".to_vec(), Object::String(pdfcraft_cos::PdfString::text("keep popup"))); + d + })); + let mut reply = doc.get(root).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"IRT".to_vec(), Object::Ref(popup)); + let reply = doc.add(Object::Dict(reply)); + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array([root, popup, reply, drawable].map(Object::Ref).to_vec())); + }) + .unwrap(); + let originals = [root, popup, reply].map(|r| (*doc.get(r)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the unrelated annotation can flatten"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [root, popup, reply].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original, "the retained popup graph stays unchanged"); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, popup, reply].map(Object::Ref)); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]); +} + +/// Undrawable text in a retained reply must not block an unrelated drawable annotation. +#[test] +fn unsupported_membership_keeps_undrawable_reply_without_refusing_flatten() { + for marked_appearance in [false, true] { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + let mut reply = doc.get(root).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"Subtype".to_vec(), Object::Name(b"FreeText".to_vec())); + reply.set(b"IRT".to_vec(), Object::Ref(root)); + reply.set(b"Contents".to_vec(), Object::String(pdfcraft_cos::PdfString::text("漢"))); + if marked_appearance { + pdfcraft_fonts::set_undrawn_mark(&mut reply, Some('漢')); + } else { + reply.remove(b"AP"); + } + assert_eq!(pdfcraft_annot::appearance::undrawable_text(&reply).map(|(_, c)| c), Some('漢')); + let reply = doc.add(Object::Dict(reply)); + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array([root, reply, drawable].map(Object::Ref).to_vec())); + }) + .unwrap(); + // Compare serialized dictionaries before and after flatten, including PDF string syntax. + let mut doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let originals = [root, reply].map(|r| (*doc.get(r)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "retained text is not baked into page content"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [root, reply].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, reply].map(Object::Ref)); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]); + } +} + +/// Verify that shared/inherited `/Resources` and `/Properties` dictionaries are never mutated in +/// place and that consecutive flatten runs allocate distinct `/PCFlOC{n}` property names. +#[test] +fn flatten_preserves_shared_resources_and_uses_new_names_on_each_call() { + for inherited in [false, true] { + for indirect in [false, true] { + let mut doc = fixture("/OC 4 0 R", 4, ""); + let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + let original_properties = resources.get(b"Properties").unwrap().clone(); + if indirect { + let r = doc.add(original_properties.clone()); + resources.set(b"Properties".to_vec(), Object::Ref(r)); + } + let resources = if indirect { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) }; + let owner = ObjRef::new(if inherited { 2 } else { 3 }, 0); + if inherited { + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } + doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap(); + let original_resource_object = (*doc.resolve(&resources)).clone(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0)))); 2]); + let page = pdfcraft_model::pages(&doc).remove(0); + let effective = doc.resolve(page.dict.get(b"Resources").unwrap()); + let properties = doc.resolve(effective.as_dict().unwrap().get(b"Properties").unwrap()); + let properties = properties.as_dict().unwrap(); + for (key, value) in original_properties.as_dict().unwrap().iter() { + assert_eq!(properties.get(key), Some(value)); + } + assert_eq!(properties.len(), original_properties.as_dict().unwrap().len() + 2); + if inherited || indirect { + assert_eq!(*doc.resolve(&resources), original_resource_object); + } + } + } +} + +/// Verify that malformed `/Resources` or `/Properties` entries retain `/OC` annotations without +/// generating appearances, while inline annotations with valid `/OC` still flatten cleanly. +#[test] +fn malformed_resources_and_direct_ocg_stay_unchanged_before_appearance_generation() { + for malformed_resources in [false, true] { + let mut doc = fixture("/OC 4 0 R", 4, ""); + if malformed_resources { + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Null)).unwrap(); + } else { + let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + resources.set(b"Properties".to_vec(), Object::Int(1)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + } + doc.update_dict(ObjRef::new(8, 0), |d| { + d.remove(b"AP"); + }) + .unwrap(); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); + } + let mut doc = fixture("/OC 4 0 R", 4, ""); + let annotation = doc.get(ObjRef::new(8, 0)).as_dict().unwrap().clone(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annotation)]))).unwrap(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0))))]); +} + +/// Inline replies and inline popups pointing to a retained annotation are kept in /Annots. +#[test] +fn unsupported_membership_keeps_inline_replies_and_popups_after_save_and_reopen() { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + + let mut inline_reply = doc.get(root).as_dict().unwrap().clone(); + inline_reply.remove(b"OC"); + inline_reply.set(b"IRT".to_vec(), Object::Ref(root)); + + let mut indirect_reply = doc.get(root).as_dict().unwrap().clone(); + indirect_reply.remove(b"OC"); + indirect_reply.set(b"IRT".to_vec(), Object::Ref(root)); + let indirect_reply = doc.add(Object::Dict(indirect_reply)); + + let mut inline_grandchild = doc.get(root).as_dict().unwrap().clone(); + inline_grandchild.remove(b"OC"); + inline_grandchild.set(b"IRT".to_vec(), Object::Ref(indirect_reply)); + + let mut inline_popup = pdfcraft_cos::Dict::new(); + inline_popup.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + inline_popup.set(b"Parent".to_vec(), Object::Ref(root)); + + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set( + b"Annots".to_vec(), + Object::Array(vec![ + Object::Dict(inline_reply.clone()), + Object::Ref(root), + Object::Ref(indirect_reply), + Object::Dict(inline_grandchild.clone()), + Object::Dict(inline_popup.clone()), + Object::Ref(drawable), + ]), + ) + }) + .unwrap(); + + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap().clone(); + + assert_eq!(annots.len(), 5); + assert_eq!(annots[0], Object::Dict(inline_reply)); + assert_eq!(annots[1], Object::Ref(root)); + assert_eq!(annots[2], Object::Ref(indirect_reply)); + assert_eq!(annots[3], Object::Dict(inline_grandchild)); + assert_eq!(annots[4], Object::Dict(inline_popup)); +}