mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
This commit is contained in:
1 parent
8c7ea6377e
commit
9792328d67
3 files changed
+759
-19
No files matched your search
+210
-19
@@ -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<Dict> {
|
||||
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<ObjRef>,
|
||||
inline: HashSet<usize>,
|
||||
}
|
||||
|
||||
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<ObjRef, Vec<ObjRef>> = HashMap::new();
|
||||
let mut inline_children: HashMap<ObjRef, Vec<usize>> = 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<String> {
|
||||
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<usize, EditError> {
|
||||
@@ -157,10 +311,15 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result<usize, E
|
||||
fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], which: Which) -> 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<ObjRef>, 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<usize, EditError> {
|
||||
@@ -218,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let Some(list) = annots_obj.as_ref().map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue };
|
||||
let mut content = String::new();
|
||||
let mut xobjects = Dict::new();
|
||||
let existing_properties = properties(doc, &page.dict);
|
||||
let mut added_properties = Dict::new();
|
||||
let mut retained = retained_annotations(doc, &list, existing_properties.is_some());
|
||||
let mut property_n = 0u64;
|
||||
let mut name_n = 0u32;
|
||||
let existing_xo = page
|
||||
.dict
|
||||
@@ -234,27 +417,16 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
// A viewer draws many comments from their dictionary even when the file stores no
|
||||
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
|
||||
// flattening keeps a comment that still has ink on screen instead of deleting it.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { 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) {
|
||||
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;
|
||||
}
|
||||
// A subtype this build cannot draw fails here and is kept below, so the comment is
|
||||
// not deleted with nothing drawn in its place. (Text the standard fonts can't draw
|
||||
// was refused before anything changed: `undrawn_on`.)
|
||||
let _ = pdfcraft_annot::set_appearance(doc, r);
|
||||
}
|
||||
prepare_appearances(doc, &list, which, &retained.refs, existing_properties.is_some());
|
||||
for (k, entry) in list.iter().enumerate() {
|
||||
let obj = doc.resolve(entry);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
let widget = subtype == b"Widget";
|
||||
if retained.contains(k, entry) || !transferable(doc, d, existing_properties.is_some()) {
|
||||
retained.insert(k, entry);
|
||||
continue;
|
||||
}
|
||||
if !selected(doc, d, which) {
|
||||
continue;
|
||||
}
|
||||
@@ -268,8 +440,20 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
|
||||
if let Some(m) = placement(doc, &form, rect) {
|
||||
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
|
||||
if let Some(membership) = d.get(b"OC") {
|
||||
let Some(existing) = existing_properties.as_ref() else { continue };
|
||||
let Some(name) = property_name(existing, &added_properties, &mut property_n) else {
|
||||
retained.insert(k, entry);
|
||||
continue;
|
||||
};
|
||||
content.push_str(&format!("/OC /{name} BDC\n"));
|
||||
added_properties.set(name.into_bytes(), membership.clone());
|
||||
}
|
||||
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
|
||||
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
|
||||
if d.contains(b"OC") {
|
||||
content.push_str("EMC\n");
|
||||
}
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
drawn += 1;
|
||||
drew = true;
|
||||
@@ -310,7 +494,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
// it could not be drawn: its content would go with nothing in its place.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
if undrawn.contains(&r) {
|
||||
if undrawn.contains(&r) || retained.refs.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
@@ -334,6 +518,13 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
xo.set(k.clone(), v.clone());
|
||||
}
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
if !added_properties.is_empty() {
|
||||
let mut properties = existing_properties.unwrap_or_default();
|
||||
for (key, value) in added_properties.iter() {
|
||||
properties.set(key.clone(), value.clone());
|
||||
}
|
||||
res.set(b"Properties".to_vec(), Object::Dict(properties));
|
||||
}
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
let page = refreshed(doc, &page);
|
||||
place_tagged(doc, &page, "Flattened", content.into_bytes(), false)?;
|
||||
|
||||
@@ -577,6 +577,83 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
|
||||
case("/BE << /S /C /I 2 >> /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]
|
||||
|
||||
@@ -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<u8> {
|
||||
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<Object>)> {
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user