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