M11: PDF/A verify checks the file identifier and Save as PDF/A adds one (6.1.3)

The trailer the last startxref points to (an update's, or a linearized
file's first-page trailer) must have an /ID of two non-empty strings
(ISO 32000-1 14.4). Save as PDF/A gives a document without a good one a
new identifier, made by the writer's generator, now public in cos as
new_file_id.

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:26:20 +08:00
co-authored by Claude Opus 5.5
parent d22641a875
commit 1336bc8896
5 changed files with 101 additions and 5 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};
pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental};
pub use writer::{SaveOptions, new_file_id, pdf_date, serialize, write_full, write_incremental};
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CosError {
+9 -3
View File
@@ -639,13 +639,19 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) {
}
}
/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4).
/// Keep an existing `/ID`, or create one with [`new_file_id`].
fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) {
return;
}
trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed));
}
/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of
/// two equal 16-byte strings, the same for the same content and `seed`.
pub fn new_file_id(content: &[u8], seed: u64) -> Object {
// FNV-1a over a sample of the content and the seed: stable, dependency-free.
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed;
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed;
for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
@@ -654,7 +660,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
id.extend_from_slice(&h.to_be_bytes());
id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes());
let s = Object::String(PdfString { bytes: id, hex: true });
trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
Object::Array(vec![s.clone(), s])
}
/// A PDF date string for "now" given seconds since the Unix epoch (UTC).
+12 -1
View File
@@ -22,6 +22,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod icc;
mod trailer;
mod xmp;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
@@ -114,6 +115,8 @@ pub const WORDINGS: &[&str] = &[
"The metadata doesn't identify the file as {}",
"The title in the metadata and the document information differ",
// The document as a whole.
"The trailer has no file identifier (ID)",
"The file identifier (ID) in the trailer isn't two non-empty strings",
"The document is encrypted",
"Device colour is used, but there is no PDF/A output intent",
"DeviceCMYK is used: it needs a CMYK output intent (not added automatically)",
@@ -454,7 +457,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
}
}
// 6.1.3: encryption.
// 6.1.3: the file identifier and encryption.
match trailer::check(doc) {
Some(trailer::IdProblem::Missing) => issue("6.1.3", "The trailer has no file identifier (ID)", None, None, true),
Some(trailer::IdProblem::Malformed) => {
issue("6.1.3", "The file identifier (ID) in the trailer isn't two non-empty strings", None, None, true)
}
None => {}
}
if doc.trailer().get(b"Encrypt").is_some() {
issue("6.1.3", "The document is encrypted", None, None, true);
}
@@ -798,6 +808,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
doc.remove_encryption();
fixed.push("Removed encryption".into());
}
fixed.extend(trailer::fix(doc));
let root = doc.root().ok_or(pdfcraft_cos::CosError::Syntax { offset: 0, detail: "no catalog".into() })?;
// Catalog: actions, JavaScript, NeedAppearances, output intent, metadata.
let mut cat = catalog(doc);
+43
View File
@@ -488,3 +488,46 @@ 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.1.3: the trailer `startxref` points to has a file identifier, two non-empty
/// strings; Save as PDF/A gives a file one.
#[test]
fn trailer_file_identifier_is_checked_and_added() {
let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
let id = |extra: &str| -> Vec<String> {
let doc = build(&objs, extra);
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.3").map(|i| i.message).collect()
};
let missing = ["The trailer has no file identifier (ID)"];
let malformed = ["The file identifier (ID) in the trailer isn't two non-empty strings"];
assert!(id("/ID [<0011> (ab)]").is_empty());
assert_eq!(id(""), missing);
assert_eq!(id("/ID null"), missing);
for wrong in ["/ID [<><>]", "/ID [<0011>]", "/ID [<0011> <0011> <0011>]", "/ID [1 2]", "/ID (ab)"] {
assert_eq!(id(wrong), malformed, "{wrong}");
}
// An update without an /ID has none, whatever the trailer it replaces had.
let base = build(&objs, "/ID [<0011> <0011>]");
let mut bytes = base.bytes().to_vec();
let prev = bytes.windows(9).rposition(|w| w == b"startxref").unwrap();
let prev: usize = String::from_utf8_lossy(&bytes[prev + 10..]).lines().next().unwrap().trim().parse().unwrap();
let at = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 3 /Root 1 0 R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
);
let updated = Document::open(std::sync::Arc::new(bytes)).unwrap();
assert!(verify(&updated, Level::A2b).iter().any(|i| i.message == missing[0]));
// Save as PDF/A adds one, which a save keeps.
for extra in ["", "/ID [<><>]"] {
let mut doc = build(&objs, extra);
let report = convert(&mut doc, Level::A2b).unwrap();
assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"), "{extra}");
assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}");
let saved = pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap();
let saved = Document::open(std::sync::Arc::new(saved)).unwrap();
assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}");
}
// A good one is kept.
let mut doc = build(&objs, "/ID [<0011> <0022>]");
assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier")));
}
+36
View File
@@ -0,0 +1,36 @@
//! PDF/A-2 6.1.3, the file trailer: it has an `/ID`, a file identifier as ISO 32000-1 14.4
//! defines it, an array of two byte strings.
//!
//! The trailer read is the one the file's last `startxref` points to: the latest update's, or a
//! linearized file's first-page trailer (ISO 32000-1 F.3.4). Trailers earlier in the chain don't
//! stand in for it.
use pdfcraft_cos::{Document, Object};
/// What is wrong with the file identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IdProblem {
/// The trailer has no `/ID`, or a null one.
Missing,
/// The `/ID` isn't an array of two non-empty strings.
Malformed,
}
/// What 6.1.3 finds wrong with the file identifier of `doc`, if anything.
pub(crate) fn check(doc: &Document) -> Option<IdProblem> {
let id = doc.trailer().get(b"ID").map(|id| doc.resolve(id)).filter(|id| !matches!(**id, Object::Null));
let Some(id) = id else { return Some(IdProblem::Missing) };
let strings = |parts: &[Object]| parts.iter().all(|p| doc.resolve(p).as_string().is_some_and(|s| !s.bytes.is_empty()));
match id.as_array() {
Some(parts) if parts.len() == 2 && strings(parts) => None,
_ => Some(IdProblem::Malformed),
}
}
/// Gives `doc` a new file identifier when it lacks a good one; what it fixed, if anything.
pub(crate) fn fix(doc: &mut Document) -> Option<String> {
check(doc)?;
let id = pdfcraft_cos::new_file_id(doc.bytes(), 0);
doc.trailer_mut().set(b"ID".to_vec(), id);
Some("Added a file identifier (ID)".into())
}