mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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:
1 parent
d22641a875
commit
d2776fa350
3 files changed
+131
-1
No files matched your search
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user