From d2776fa350dff6e6289b8d6a90cede25155cb36f Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:54:30 +0800 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/header.rs | 69 ++++++++++++++++++++++++++++++++++ crates/preflight/src/lib.rs | 13 +++++++ crates/preflight/src/tests.rs | 50 +++++++++++++++++++++++- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 crates/preflight/src/header.rs diff --git a/crates/preflight/src/header.rs b/crates/preflight/src/header.rs new file mode 100644 index 0000000000..6bd01eaf3f --- /dev/null +++ b/crates/preflight/src/header.rs @@ -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 { + 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()) +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..cae1b98df7 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -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 { } } } + // 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 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 { + 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")); +} From 307172fbd1d61640dff10f6e7894b14d54afb3a3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:55:43 +0800 Subject: [PATCH 2/4] M11: parity: PDF/A verify and Save as PDF/A cover the file header (6.1.2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/automation/src/tools.rs | 2 +- parity/acrobat-features.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f6afb7966a..b31e3218a5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1277,7 +1277,7 @@ pub fn tools() -> Vec { t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), - t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") + t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..5f007d170d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. 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::file_header_is_checked_and_rewritten", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "PDF/A-2b/3b structural rules: the file header (6.1.2), identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" @@ -7955,8 +7955,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] -notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] +notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship, the file header (a full save at version 1.7 at most). Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]] id = "optimize.pdfa-accessible-levels" From 122a155126eabd126c385528e34be8b0cc0588af Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:16:02 +0800 Subject: [PATCH 3/4] M11: tests: Save as PDF/A saves a compliant header through the ordinary save (6.1.2) An engine test converts files without a binary comment and with a 2.0 header, saves them through Session::save_bytes and verifies the reopened bytes; the preflight test adds a good version with a short comment, and a good header that conversion leaves alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/engine/src/tests.rs | 19 +++++++++++++++++++ crates/preflight/src/header.rs | 2 ++ crates/preflight/src/tests.rs | 11 +++++++++++ 3 files changed, 32 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c54131d02..745b505aa1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10480,3 +10480,22 @@ fn imported_bookmarks_undo_in_one_step() { assert!(err.to_string().contains("no bookmark"), "{err}"); assert!(s.get(id).unwrap().info.outline.is_empty()); } + +/// PDF/A-2 6.1.2 through the ordinary save: Save as PDF/A on a file without a binary comment, or +/// with a 2.0 header, saves a file whose header verifies. +#[test] +fn save_as_pdfa_saves_a_compliant_file_header() { + let plain = fixture(1); + assert!(plain.starts_with(b"%PDF-1.7\n1 0 obj"), "the fixture has no binary comment"); + let mut newer = plain.clone(); + newer.splice(5..8, *b"2.0"); + for bytes in [plain, newer] { + let mut s = Session::new(); + let id = s.open("x.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::ConvertPdfA { level: pdfcraft_preflight::Level::A2b }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"), "{:?}", String::from_utf8_lossy(&saved[..20])); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!(pdfcraft_preflight::verify(&reopened, pdfcraft_preflight::Level::A2b).iter().all(|i| i.clause != "6.1.2")); + } +} diff --git a/crates/preflight/src/header.rs b/crates/preflight/src/header.rs index 6bd01eaf3f..3e9263ef28 100644 --- a/crates/preflight/src/header.rs +++ b/crates/preflight/src/header.rs @@ -16,6 +16,8 @@ pub(crate) struct Header { pub(crate) comment: bool, } +/// What the header of `doc` breaks of 6.1.2: as stored, or, when the next save rewrites the +/// file, as that save will write it. pub(crate) fn check(doc: &Document) -> Header { if doc.full_save_required() || doc.encryption_changed() { let written = doc.version().get(..3).unwrap_or_default(); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e53e6f7a46..5d0b320068 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -535,4 +535,15 @@ fn file_header_is_checked_and_rewritten() { 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")); + // A good version with a short comment is rewritten too, keeping its version. + let mut doc = build_with_header(b"%PDF-1.4\n%ab\n", &objs, ""); + assert!(!doc.full_save_required()); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert!(doc.full_save_required()); + assert_eq!(doc.version(), "1.4"); + // A good header is left as it is. + let mut doc = build(&objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.fixed.iter().any(|f| f == "Rewrote the file header")); } From 5f06cfaa55925498522c3504a901b1c00a368af3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:16:39 +0800 Subject: [PATCH 4/4] M11: parity: Save as PDF/A cites the engine header test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5f007d170d..a6a3614fd2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7955,7 +7955,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/engine/src/tests.rs::save_as_pdfa_saves_a_compliant_file_header", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship, the file header (a full save at version 1.7 at most). Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]]