mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #6215: M11: PDF/A verify reports SetState, NOP and Named actions other than the four page moves (optimize.pdfa-validate)
# Conflicts: # crates/preflight/src/tests.rs # parity/acrobat-features.toml
This commit is contained in:
commit
b3a5ac3ac1
3 files changed
+157
-9
No files matched your search
@@ -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`.
|
||||
@@ -228,8 +230,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 {
|
||||
@@ -1059,10 +1076,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(doc, d) {
|
||||
return Some(s);
|
||||
}
|
||||
match d.get(b"Next") {
|
||||
Some(Object::Dict(next)) => stack.push(next),
|
||||
@@ -1079,6 +1094,24 @@ 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`]. The type and the name may each be given
|
||||
/// directly or by reference.
|
||||
fn action_forbidden(doc: &Document, d: &Dict) -> Option<String> {
|
||||
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());
|
||||
}
|
||||
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))),
|
||||
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) {
|
||||
|
||||
@@ -1742,3 +1742,118 @@ fn trailer_file_identifier_is_checked_and_added() {
|
||||
convert(&mut doc, Level::A2b).unwrap();
|
||||
assert_eq!(first(&doc).map(|b| b.len()), Some(32767));
|
||||
}
|
||||
|
||||
/// 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 >>",
|
||||
"/NextPage",
|
||||
"/Named",
|
||||
"/NOP",
|
||||
],
|
||||
"",
|
||||
)
|
||||
};
|
||||
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}");
|
||||
}
|
||||
// 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);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -8017,8 +8017,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/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_extension_naming_entries_are_not_empty", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/preflight/src/tests.rs::implementation_limits_are_reported", "crates/preflight/src/tests.rs::implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes", "crates/preflight/src/tests.rs::implementation_limits_cover_reals_and_cids", "crates/preflight/src/tests.rs::separation_and_devicen_spaces_are_checked", "crates/preflight/src/tests.rs::separation_and_devicen_checks_follow_references_depth_and_nchannel", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::extended_graphics_states_are_checked", "crates/preflight/src/tests.rs::graphics_state_scan_reads_each_object_once_whatever_the_generation", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added"]
|
||||
notes = "PDF/A-2b/3b structural rules: the file header (6.1.2), identification, the trailer file identifier (6.1.3), the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, none of the naming entries empty, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions with the primary components of ISO 32000-1 10.5.5.6, rendering intents), annotations, actions, images, embedded files, the implementation limits of 6.1.13 (integers, reals, strings, names, CIDs and page boundaries, ISO 32000-1 Annex C), and Separation and DeviceN spaces (6.2.4.4: one definition per colorant name, spot colorants in /Colorants; a device alternate without a matching output intent is not yet reported). 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::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_extension_naming_entries_are_not_empty", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/preflight/src/tests.rs::implementation_limits_are_reported", "crates/preflight/src/tests.rs::implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes", "crates/preflight/src/tests.rs::implementation_limits_cover_reals_and_cids", "crates/preflight/src/tests.rs::separation_and_devicen_spaces_are_checked", "crates/preflight/src/tests.rs::separation_and_devicen_checks_follow_references_depth_and_nchannel", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::extended_graphics_states_are_checked", "crates/preflight/src/tests.rs::graphics_state_scan_reads_each_object_once_whatever_the_generation", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added", "crates/preflight/src/tests.rs::set_state_no_op_and_named_actions_are_checked"]
|
||||
notes = "PDF/A-2b/3b structural rules: the file header (6.1.2), identification, the trailer file identifier (6.1.3), the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, none of the naming entries empty, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions with the primary components of ISO 32000-1 10.5.5.6, rendering intents), annotations, actions (with SetState, NOP and Named actions other than the four page moves), images, embedded files, the implementation limits of 6.1.13 (integers, reals, strings, names, CIDs and page boundaries, ISO 32000-1 Annex C), and Separation and DeviceN spaces (6.2.4.4: one definition per colorant name, spot colorants in /Colorants; a device alternate without a matching output intent is not yet reported). Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.pdfa-convert"
|
||||
|
||||
Reference in new issue
Block a user