From 7c83ff748e23584aa57e26daf25d90cd05bc6dee Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:44:11 +0800 Subject: [PATCH] M11: Verify PDF/A checks the implementation limits (ISO 19005-2 6.1.13) PDF/A-2 6.1.13 keeps a file within the limits of ISO 32000-1 Annex C (Table C.1 and the page sizes after it). Verify PDF/A now reports an integer outside 32 bits, a string over 32767 bytes, a name over 127 bytes (counted decoded, so #xx escapes don't inflate it) in an object or in content, and a page boundary under 3 or over 14400 units, read after inheritance so a page's own box overrides its parent's. Character identifiers over 65535 are not checked yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 21 ++++++ crates/preflight/src/limits.rs | 113 +++++++++++++++++++++++++++++++++ crates/preflight/src/tests.rs | 40 ++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 crates/preflight/src/limits.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..63ceaf9aa1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -22,6 +22,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod icc; +mod limits; mod xmp; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -115,6 +116,10 @@ pub const WORDINGS: &[&str] = &[ "The title in the metadata and the document information differ", // The document as a whole. "The document is encrypted", + "An integer is outside the 32-bit range", + "A string is longer than 32767 bytes", + "A name is longer than 127 bytes", + "Page {} has a boundary smaller than 3 or larger than 14400 units", "Device colour is used, but there is no PDF/A output intent", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", @@ -515,6 +520,22 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } check_objects(doc, level, &objs, &page_of, &mut issue); + // 6.1.13: the implementation limits of ISO 32000-1 Annex C. + let over = limits::exceeded(&objs, &referenced_content(doc, &objs)); + if over.int { + issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); + } + if over.string { + issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); + } + if over.name { + issue("6.1.13", "A name is longer than 127 bytes", None, None, false); + } + for (i, p) in pages.iter().enumerate() { + if limits::page_box_out_of_range(doc, &p.dict) { + issue("6.1.13", "Page {} has a boundary smaller than 3 or larger than 14400 units", Some((i + 1).to_string()), Some(i), false); + } + } // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs new file mode 100644 index 0000000000..0c86e78d46 --- /dev/null +++ b/crates/preflight/src/limits.rs @@ -0,0 +1,113 @@ +//! PDF/A-2 6.1.13: a file stays within the implementation limits of ISO 32000-1 Annex C (Table C.1 +//! and the page size limits after it): integers that fit in 32 bits, strings of at most 32767 +//! bytes, names of at most 127 bytes, and page boundaries between 3 and 14400 units wide and high. +//! +//! The values are read from the indirect objects and from the content streams, as parsed: a name +//! written with `#xx` escapes counts its decoded bytes. Character identifiers above 65535 (in a +//! CMap) and the real-number range are not checked here. + +use std::collections::HashSet; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// Table C.1: the smallest and largest integer values. +const MIN_INT: i64 = i32::MIN as i64; +const MAX_INT: i64 = i32::MAX as i64; +/// Table C.1: the longest string, in bytes. +const MAX_STRING: usize = 32767; +/// Table C.1: the longest name, in bytes. +const MAX_NAME: usize = 127; +/// The smallest and largest page boundary, in default user space units. +const MIN_PAGE: f64 = 3.0; +const MAX_PAGE: f64 = 14400.0; +/// How deep a direct object is followed (arrays and dictionaries inside one another). +const MAX_DEPTH: u8 = 32; +/// The page boundaries, of which only the media and crop boxes are inherited. +const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox"]; + +/// Which limits a value went past. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Exceeded { + pub(crate) int: bool, + pub(crate) string: bool, + pub(crate) name: bool, +} + +impl Exceeded { + fn all(self) -> bool { + self.int && self.string && self.name + } + + fn name(&mut self, n: &[u8]) { + self.name |= n.len() > MAX_NAME; + } + + /// One value and, within [`MAX_DEPTH`], what it holds: a dictionary's keys count as names. + fn value(&mut self, o: &Object, depth: u8) { + if self.all() || depth > MAX_DEPTH { + return; + } + match o { + Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, + Object::Name(n) => self.name(n), + Object::Array(a) => { + for v in a { + self.value(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => self.dict(d, depth), + Object::Stream(s) => self.dict(&s.dict, depth), + Object::Null | Object::Bool(_) | Object::Real(_) | Object::Ref(_) => {} + } + } + + fn dict(&mut self, d: &Dict, depth: u8) { + for (k, v) in d.iter() { + self.name(k); + self.value(v, depth.saturating_add(1)); + } + } +} + +/// The limits that the indirect objects `objs`, and the content streams among them (`content`: +/// the ones only a reference says hold content, besides form XObjects and appearance streams), go +/// past. +pub(crate) fn exceeded(objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { + let mut found = Exceeded::default(); + for (r, o) in objs { + if found.all() { + break; + } + found.value(o, 0); + if let Object::Stream(s) = &**o + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = s.decoded() + { + for op in pdfcraft_content::ops(&data) { + if found.all() { + break; + } + for v in &op.operands { + found.value(v, 0); + } + if let Some((image, _)) = &op.inline { + found.dict(image, 0); + } + } + } + } + found +} + +/// Whether one of a page's boundaries (with the media and crop boxes it inherits) is narrower or +/// lower than 3 units, or wider or higher than 14400. +pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { + BOXES.iter().filter_map(|k| page.get(k)).any(|b| { + let b = doc.resolve(b); + let v: Vec = b.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + let [x0, y0, x1, y1] = v.as_slice() else { return false }; + let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); + [w, h].iter().any(|s| s.is_finite() && !(MIN_PAGE..=MAX_PAGE).contains(s)) + }) +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..5adf411714 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,43 @@ 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.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): +/// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name +/// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page +/// boundary under 3 or over 14400 units, read after inheritance, so a page that overrides its +/// parent's box is judged by its own. +#[test] +fn implementation_limits_are_reported() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let doc = |extra: &str, content: &str, parent_box: &str, page_box: &str| { + let objects = [ + format!("<< /Type /Catalog /Pages 2 0 R {extra} >>"), + format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {parent_box} >>"), + format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R {page_box} >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + build(&refs, "") + }; + let ok = "[0 0 200 200]"; + assert!(limits(&doc("", "", ok, "")).is_empty(), "{:?}", limits(&doc("", "", ok, ""))); + assert_eq!(limits(&doc("/Big 2147483648", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&doc("/Small -2147483649", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert!(limits(&doc("/Edge -2147483648 /Top 2147483647", "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "2147483648 0 Td", ok, "")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&doc(&format!("/S ({long})"), "", ok, "")), ["A string is longer than 32767 bytes"]); + assert_eq!(limits(&doc("", &format!("BT ({long}) Tj ET"), ok, "")), ["A string is longer than 32767 bytes"]); + assert!(limits(&doc(&format!("/S ({})", "x".repeat(32767)), "", ok, "")).is_empty()); + let name = "n".repeat(128); + assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]); + // 200 bytes as written, 66 once the escapes are read. + let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4)); + assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "", "[0 0 2 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's"); +}