mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 02:33:51 +00:00
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:
co-authored by
Claude Opus 5.5
parent
d22641a875
commit
ea51204d5f
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user