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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410
2026-11-05 09:36:59 +08:00
co-authored by Claude Opus 5.5
parent d22641a875
commit ea51204d5f
2 changed files with 73 additions and 7 deletions
+37 -7
View File
@@ -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<String>,
}
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<String> {
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) {
+36
View File
@@ -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<String> {
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}");
}
}