From d9abc9b82ced7b2a931e461b5cdeaf62c2a6982a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:00:38 +0800 Subject: [PATCH] M11: PDF/A verify reads a Named action's name through a reference (6.5.1) A Named action whose /N refers to /NextPage (or another page move) is allowed and survives Save as PDF/A; the test checks that conversion keeps such an action. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 10 ++++++---- crates/preflight/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 0c322ecba3..c615b7cb9f 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -770,7 +770,7 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec if *read > MAX_ACTION_CHAIN { return Some("too deeply chained".into()); } - if let Some(s) = action_forbidden(d) { + if let Some(s) = action_forbidden(doc, d) { return Some(s); } match d.get(b"Next") { @@ -789,13 +789,15 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec } /// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named -/// action for anything but the four of [`NAMED_ACTIONS`]. -fn action_forbidden(d: &Dict) -> Option { +/// action for anything but the four of [`NAMED_ACTIONS`], its name given directly or by +/// reference. +fn action_forbidden(doc: &Document, d: &Dict) -> Option { let s = d.name(b"S")?; if FORBIDDEN_ACTIONS.contains(&s) { return Some(String::from_utf8_lossy(s).into_owned()); } - match d.name(b"N") { + let n = d.get(b"N").map(|n| doc.resolve(n)); + match n.as_deref().and_then(Object::as_name) { _ if s != b"Named" => None, Some(n) if NAMED_ACTIONS.contains(&n) => None, Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))), diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 17816823e2..dc5991d67f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -501,6 +501,7 @@ fn set_state_no_op_and_named_actions_are_checked() { "<< /Type /Pages /Kids [] /Count 0 >>", &outline, "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", + "/NextPage", ], "", ) @@ -517,10 +518,26 @@ fn set_state_no_op_and_named_actions_are_checked() { for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] { assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}"); } + // The name given by reference (object 5 is /NextPage). + assert!(actions("<< /S /Named /N 5 0 R >>").is_empty()); // Save as PDF/A removes them. for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] { let mut doc = doc_with(action); convert(&mut doc, Level::A2b).unwrap(); assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.5.1"), "{action}"); } + // A permitted Named action survives it, also one naming its page move by reference. + for action in ["<< /S /Named /N /NextPage >>", "<< /S /Named /N 5 0 R >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + let outline = doc.get(ObjRef { num: 3, generation: 0 }); + let kept = outline + .as_dict() + .and_then(|d| d.get(b"A")) + .and_then(Object::as_dict) + .map(|a| (a.name(b"S").map(<[u8]>::to_vec), a.get(b"N").map(|n| doc.resolve(n)))); + let (s, n) = kept.unwrap_or_else(|| panic!("{action}: the action is gone")); + assert_eq!(s.as_deref(), Some(&b"Named"[..]), "{action}"); + assert_eq!(n.as_deref().and_then(Object::as_name), Some(&b"NextPage"[..]), "{action}"); + } }