M11: PDF/A verify checks the file header and Save as PDF/A rewrites it (6.1.2)

The file must start with %PDF-1.0 to %PDF-1.7 and an end-of-line marker,
followed by a comment of % and four bytes above 127. A document whose
next save is a full rewrite is judged by the header that save writes.
Save as PDF/A makes the next save full, at version 1.7 when the kept
version isn't 1.0 to 1.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 07:54:30 +08:00
1 parent d22641a875
commit d2776fa350
3 files changed
+131 -1

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
//! PDF/A-2 6.1.2, the file header: the file starts at its first byte with `%PDF-1.n`, `n` from 0
//! to 7, and an end-of-line marker; the next line is a comment, `%` and at least four bytes each
//! above 127, which marks the file as binary (ISO 32000-1 7.5.2).
//!
//! A document whose next save rewrites it in full is judged by the header that save writes: the
//! version it keeps, followed by the writer's own binary comment.
use pdfcraft_cos::Document;
/// What the header of `doc`, as it will be saved, breaks of 6.1.2.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Header {
/// The first line isn't `%PDF-1.0` to `%PDF-1.7` at the start of the file.
pub(crate) version: bool,
/// The second line isn't a comment of at least four bytes above 127.
pub(crate) comment: bool,
}
pub(crate) fn check(doc: &Document) -> Header {
if doc.full_save_required() || doc.encryption_changed() {
let written = doc.version().get(..3).unwrap_or_default();
return Header { version: !allowed(written.as_bytes()), comment: false };
}
let data = doc.bytes();
let Some(rest) = data.strip_prefix(b"%PDF-") else { return Header { version: true, comment: false } };
let version = rest.get(..3).unwrap_or_default();
let Some(rest) = rest.get(3..).and_then(eol) else {
return Header { version: true, comment: !binary_comment(after_line(rest)) };
};
Header { version: !allowed(version), comment: !binary_comment(rest) }
}
/// Whether a version is `1.0` to `1.7`.
fn allowed(version: &[u8]) -> bool {
matches!(version, [b'1', b'.', b'0'..=b'7'])
}
/// What follows an end-of-line marker (CR, LF or CR LF) at the start of `data`.
fn eol(data: &[u8]) -> Option<&[u8]> {
match data {
[b'\r', b'\n', rest @ ..] | [b'\r', rest @ ..] | [b'\n', rest @ ..] => Some(rest),
_ => None,
}
}
/// What follows the first end-of-line marker in `data`, or nothing.
fn after_line(data: &[u8]) -> &[u8] {
let end = data.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(data.len());
data.get(end..).and_then(eol).unwrap_or_default()
}
/// Whether `data` starts with `%` and four bytes above 127.
fn binary_comment(data: &[u8]) -> bool {
matches!(data, [b'%', a, b, c, d, ..] if [a, b, c, d].iter().all(|&&x| x > 127))
}
/// Makes the next save of `doc` write a header 6.1.2 allows, when the current one breaks it:
/// a full save, at version 1.7 when the version kept isn't 1.0 to 1.7. What it fixed, if anything.
pub(crate) fn rewrite(doc: &mut Document) -> Option<String> {
let header = check(doc);
if !header.version && !header.comment {
return None;
}
if !allowed(doc.version().as_bytes().get(..3).unwrap_or_default()) {
doc.set_version("1.7");
}
doc.require_full_save();
Some("Rewrote the file header".into())
}
+13
View File
@@ -21,6 +21,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod header;
pub mod icc;
mod xmp;
@@ -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 file doesn't start with a %PDF-1.0 to %PDF-1.7 header line",
"The file header isn't followed by a comment of four bytes above 127",
"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,6 +457,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
}
}
// 6.1.2: the file header.
let header = header::check(doc);
if header.version {
issue("6.1.2", "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", None, None, true);
}
if header.comment {
issue("6.1.2", "The file header isn't followed by a comment of four bytes above 127", None, None, true);
}
// 6.1.3: encryption.
if doc.trailer().get(b"Encrypt").is_some() {
issue("6.1.3", "The document is encrypted", None, None, true);
@@ -798,6 +809,8 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
doc.remove_encryption();
fixed.push("Removed encryption".into());
}
// A full save writes the header afresh, with the version it keeps and a binary comment.
fixed.extend(header::rewrite(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);
+49 -1
View File
@@ -1,7 +1,11 @@
use super::*;
fn build(objs: &[&str], trailer_extra: &str) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
build_with_header(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", objs, trailer_extra)
}
fn build_with_header(header: &[u8], objs: &[&str], trailer_extra: &str) -> Document {
let mut out = header.to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
@@ -488,3 +492,47 @@ 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.2: the file starts with `%PDF-1.0` to `%PDF-1.7` and an end-of-line marker, then a
/// comment of `%` and four bytes above 127; Save as PDF/A rewrites a header that doesn't.
#[test]
fn file_header_is_checked_and_rewritten() {
let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
let header = |h: &[u8]| -> Vec<String> {
let doc = build_with_header(h, &objs, "");
verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.2").map(|i| i.message).collect()
};
let version = || vec!["The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line".to_string()];
let comment = || vec!["The file header isn't followed by a comment of four bytes above 127".to_string()];
for fine in [&b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"[..], b"%PDF-1.4\r\n%\x80\x81\x82\x83af\r\n", b"%PDF-1.0\r%\xFF\xFF\xFF\xFF\r"] {
assert!(header(fine).is_empty(), "{}", String::from_utf8_lossy(fine));
}
let wrong_version = [
&b"%PDF-2.0\n%\xE2\xE3\xCF\xD3\n"[..],
b"%PDF-1.9\n%\xE2\xE3\xCF\xD3\n",
b"%PDF-1.70\n%\xE2\xE3\xCF\xD3\n",
b"%PDF-1.7 \n%\xE2\xE3\xCF\xD3\n",
b"SomeData\n%PDF-1.7\n%\xE2\xE3\xCF\xD3\n",
];
for wrong in wrong_version {
assert_eq!(header(wrong), version(), "{}", String::from_utf8_lossy(wrong));
}
let wrong_comment = [
&b"%PDF-1.7\n%\xE2\xE3\xCF\n"[..],
b"%PDF-1.7\ninvalidData\n",
b"%PDF-1.7\n%a\xE3\xCF\xD3\n",
b"%PDF-1.7\n\n%\xE2\xE3\xCF\xD3\n",
b"%PDF-1.7\n",
];
for wrong in wrong_comment {
assert_eq!(header(wrong), comment(), "{}", String::from_utf8_lossy(wrong));
}
// Save as PDF/A rewrites the header: the version drops to 1.7 and the next save is full.
let mut doc = build_with_header(b"%PDF-2.0\n%ab\n", &objs, "");
let report = convert(&mut doc, Level::A2b).unwrap();
assert!(report.fixed.iter().any(|f| f == "Rewrote the file header"));
assert_eq!(doc.version(), "1.7");
assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.2"));
let saved = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"));
}