From ea51204d5f2907fe9d94602b809ff67f156758dc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:36:59 +0800 Subject: [PATCH 1/5] M11: PDF/A verify reports SetState, NOP and Named actions beyond the four page moves (6.5.1) The PDF 1.2 SetState and NOP actions, which ISO 32000-1 no longer defines, are forbidden, and a Named action may only name NextPage, PrevPage, FirstPage or LastPage (ISO 32000-1 Table 212). Save as PDF/A removes the others as it does other forbidden actions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 44 +++++++++++++++++++++++++++++------ crates/preflight/src/tests.rs | 36 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..0c322ecba3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -10,7 +10,9 @@ //! - every font embedded; //! - annotations: allowed types, printable, not hidden, with appearance streams; //! - actions: none of the forbidden kinds (JavaScript, Launch, Sound, Movie, ResetForm, -//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView); no additional actions on the +//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView, and the PDF 1.2 SetState and +//! NOP that ISO 32000-1 no longer defines); Named actions only for NextPage, PrevPage, +//! FirstPage and LastPage (ISO 32000-1 12.6.4.11, Table 212); no additional actions on the //! catalog, pages, fields or widgets; `NeedAppearances` not true; //! - images: no `Interpolate`, no `Alternates`, no OPI; no PostScript XObjects; //! - embedded files: PDF/A-2 only allows embedded PDF/A files; PDF/A-3 needs `AFRelationship`. @@ -152,8 +154,23 @@ pub struct Declared { pub output_intents: Vec, } -const FORBIDDEN_ACTIONS: &[&[u8]] = - &[b"JavaScript", b"Launch", b"Sound", b"Movie", b"ResetForm", b"ImportData", b"Hide", b"SetOCGState", b"Rendition", b"Trans", b"GoTo3DView"]; +const FORBIDDEN_ACTIONS: &[&[u8]] = &[ + b"JavaScript", + b"Launch", + b"Sound", + b"Movie", + b"ResetForm", + b"ImportData", + b"Hide", + b"SetOCGState", + b"Rendition", + b"Trans", + b"GoTo3DView", + b"SetState", + b"NOP", +]; +/// The named actions of ISO 32000-1 Table 212, the only ones a Named action may name. +const NAMED_ACTIONS: &[&[u8]] = &[b"NextPage", b"PrevPage", b"FirstPage", b"LastPage"]; const FORBIDDEN_ANNOTS: &[&[u8]] = &[b"Sound", b"Movie", b"Screen", b"3D", b"RichMedia"]; fn catalog(doc: &Document) -> Dict { @@ -753,10 +770,8 @@ 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) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - { - return Some(String::from_utf8_lossy(s).into_owned()); + if let Some(s) = action_forbidden(d) { + return Some(s); } match d.get(b"Next") { Some(Object::Dict(next)) => stack.push(next), @@ -773,6 +788,21 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec None } +/// 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 { + 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") { + _ if s != b"Named" => None, + Some(n) if NAMED_ACTIONS.contains(&n) => None, + Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))), + None => Some("Named".into()), + } +} + /// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..17816823e2 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,39 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.5.1: the PDF 1.2 SetState and NOP actions aren't allowed, and a Named action may +/// only name NextPage, PrevPage, FirstPage or LastPage; Save as PDF/A removes the others. +#[test] +fn set_state_no_op_and_named_actions_are_checked() { + let doc_with = |action: &str| { + let outline = format!("<< /Title (x) /Parent 4 0 R /A {action} >>"); + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + &outline, + "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", + ], + "", + ) + }; + let actions = |action: &str| -> Vec { + verify(&doc_with(action), Level::A2b).into_iter().filter(|i| i.clause == "6.5.1").map(|i| i.message).collect() + }; + assert_eq!(actions("<< /Type /Action /S /SetState /T [] >>"), ["A SetState action isn't allowed"]); + assert_eq!(actions("<< /S /NOP >>"), ["A NOP action isn't allowed"]); + assert_eq!(actions("<< /S /Named /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S /Named >>"), ["A Named action isn't allowed"]); + // Behind an allowed action, in its /Next. + assert_eq!(actions("<< /S /GoTo /D [0 /Fit] /Next << /S /NOP >> >>"), ["A NOP action isn't allowed"]); + for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] { + assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}"); + } + // 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}"); + } +} From 33cd765828139d8595964fda0a8e1ca3666e4538 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:37:22 +0800 Subject: [PATCH 2/5] M11: parity: PDF/A verify covers SetState, NOP and Named actions (6.5.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..e3d0ba80fa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::set_state_no_op_and_named_actions_are_checked", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions (with SetState, NOP and Named actions other than the four page moves), images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From d9abc9b82ced7b2a931e461b5cdeaf62c2a6982a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:00:38 +0800 Subject: [PATCH 3/5] 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}"); + } } From 75b82de0d59778de32f53abd9b48d3a421338335 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:14:44 +0800 Subject: [PATCH 4/5] M11: PDF/A verify reads an action's type through a reference (6.5.1) An action whose /S refers to /Named or /NOP is checked like one that names it directly, so verification reports it and conversion removes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 7 ++++--- crates/preflight/src/tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index c615b7cb9f..9e83218a70 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -789,10 +789,11 @@ 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`], its name given directly or by -/// reference. +/// action for anything but the four of [`NAMED_ACTIONS`]. The type and the name may each be given +/// directly or by reference. fn action_forbidden(doc: &Document, d: &Dict) -> Option { - let s = d.name(b"S")?; + let s = doc.resolve(d.get(b"S")?); + let s = s.as_name()?; if FORBIDDEN_ACTIONS.contains(&s) { return Some(String::from_utf8_lossy(s).into_owned()); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index dc5991d67f..b8f350b19d 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -502,6 +502,8 @@ fn set_state_no_op_and_named_actions_are_checked() { &outline, "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", "/NextPage", + "/Named", + "/NOP", ], "", ) @@ -520,6 +522,10 @@ fn set_state_no_op_and_named_actions_are_checked() { } // The name given by reference (object 5 is /NextPage). assert!(actions("<< /S /Named /N 5 0 R >>").is_empty()); + // The type given by reference (object 6 is /Named, 7 is /NOP). + assert_eq!(actions("<< /S 6 0 R /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S 7 0 R >>"), ["A NOP action isn't allowed"]); + assert!(actions("<< /S 6 0 R /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); From 3fe8ee4774aabd3c411249fde6d1fe5d41591720 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Mon, 9 Nov 2026 08:54:03 +0800 Subject: [PATCH 5/5] M11: PDF/A verify checks SetState, NOP and Named actions in open actions, standalone actions and links (6.5.1) The test now places each of them in the catalog's /OpenAction (direct and by reference), in an action of its own that a page's /AA refers to, and in a link's /A, and adds a Named action whose /N refers to /Print. Save as PDF/A removes each and keeps a Named page move. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/preflight/src/tests.rs | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 6696a2ff51..c1fd49bc3b 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -550,6 +550,62 @@ fn set_state_no_op_and_named_actions_are_checked() { } } +/// The SetState, NOP and Named checks of 6.5.1 apply wherever an action can be: the catalog's +/// `/OpenAction`, an action standing alone as an object of its own (here one a page's `/AA` +/// refers to), and a link's `/A`, not only an outline item's. A Named action's `/N` given by +/// reference to a forbidden name is reported by that name. Save as PDF/A removes each one and +/// keeps a Named page move in the same places. +#[test] +fn set_state_no_op_and_named_actions_are_checked_wherever_actions_are() { + // Object 5 is the standalone action, 6 is /Print and 7 is /NextPage. + let doc_with = |open_action: &str, page_aa: &str, link_action: &str, standalone: &str| { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {open_action} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] {page_aa} >>"); + let link = format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 {link_action} >>"); + build(&[&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, &link, standalone, "/Print", "/NextPage"], "") + }; + let goto = "<< /S /GoTo /D [3 0 R /Fit] >>"; + // Where the action is, as (open action, page /AA, link /A, object 5). + let placed = |action: &str| { + [ + ("an open action", doc_with(&format!("/OpenAction {action}"), "", "", goto)), + ("an open action by reference", doc_with("/OpenAction 5 0 R", "", "", action)), + ("a page's /AA", doc_with("", "/AA << /O 5 0 R >>", "", action)), + ("a link's /A", doc_with("", "", &format!("/A {action}"), goto)), + ] + }; + let forbidden = [ + ("<< /S /SetState /T [] >>", "A SetState action isn't allowed"), + ("<< /S /NOP >>", "A NOP action isn't allowed"), + ("<< /S /Named /N /Print >>", "A Named Print action isn't allowed"), + ("<< /S /Named /N 6 0 R >>", "A Named Print action isn't allowed"), + ]; + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + for (action, message) in forbidden { + for (place, mut doc) in placed(action) { + let issues = verify(&doc, Level::A2b); + let found: Vec<&str> = issues.iter().filter(|i| i.clause == "6.5.1").map(|i| i.message.as_str()).collect(); + assert_eq!(found, [message], "{action} in {place}: {issues:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + let left: Vec<_> = r.remaining.iter().filter(|i| i.clause.starts_with("6.5")).collect(); + assert!(left.is_empty(), "{action} in {place}: {left:#?}"); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + for kind in [&b"/SetState"[..], b"/NOP", b"/Named"] { + assert!(!bytes.windows(kind.len()).any(|w| w == kind), "{action} in {place}: {}", String::from_utf8_lossy(&bytes)); + } + } + } + // A Named page move, with its name given by reference, is allowed and kept outside /AA. + for (place, mut doc) in placed("<< /S /Named /N 7 0 R >>") { + let issues = verify(&doc, Level::A2b); + assert!(!issues.iter().any(|i| i.clause == "6.5.1"), "{place}: {issues:#?}"); + convert(&mut doc, Level::A2b).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + let kept = bytes.windows(6).any(|w| w == b"/Named"); + assert_eq!(kept, place != "a page's /AA", "{place}: {}", String::from_utf8_lossy(&bytes)); + } +} + const XMP_NS: &str = concat!( r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" "#, r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#,