From 7c83ff748e23584aa57e26daf25d90cd05bc6dee Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:44:11 +0800 Subject: [PATCH 1/5] 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"); +} From e21c600cdba36edfa417011371238573a059ae07 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:02:51 +0800 Subject: [PATCH 2/5] M11: 6.1.13 reads the trailer, joined page contents and deep values, within one budget From the review of #6202: - the trailer is scanned with the indirect objects; - a page's /Contents streams are read joined, as one content stream: operands may end one stream and their operator start the next (ISO 32000-1 7.8.2); - values are followed as deep as the parser nests them (128); - content is decoded within one 256 MiB allowance for the document; - a page box whose size overflows to infinity is reported; - the test covers a long name used as a content operand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 2 +- crates/preflight/src/limits.rs | 104 ++++++++++++++++++++++++--------- crates/preflight/src/tests.rs | 40 +++++++++++++ 3 files changed, 118 insertions(+), 28 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 63ceaf9aa1..059fff4a36 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,7 +521,7 @@ 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)); + let over = limits::exceeded(doc, &objs, &referenced_content(doc, &objs)); if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index 0c86e78d46..7930cb9bc4 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -2,13 +2,16 @@ //! 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. +//! The values are read from the trailer, the indirect objects and the content streams, as parsed: +//! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read +//! joined, as one content stream (an operator's operands may end one stream and the operator start +//! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. +//! Character identifiers above 65535 (in a CMap) and the real-number range are not checked here, +//! nor an integer token too long for 64 bits, which the parser reads as a real. use std::collections::HashSet; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; /// Table C.1: the smallest and largest integer values. const MIN_INT: i64 = i32::MIN as i64; @@ -20,8 +23,11 @@ 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; +/// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as +/// the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The decoded content, across all the document's content streams, the scan reads. +const MAX_CONTENT_BYTES: usize = 256 << 20; /// 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"]; @@ -62,6 +68,21 @@ impl Exceeded { } } + /// The operands of one content stream, inline images' dictionaries included. + fn content(&mut self, data: &[u8]) { + for op in pdfcraft_content::ops(data) { + if self.all() { + return; + } + for v in &op.operands { + self.value(v, 0); + } + if let Some((image, _)) = &op.inline { + self.dict(image, 0); + } + } + } + fn dict(&mut self, d: &Dict, depth: u8) { for (k, v) in d.iter() { self.name(k); @@ -70,36 +91,64 @@ impl Exceeded { } } -/// 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 { +/// The limits that the trailer, the indirect objects `objs` and the content streams go past. +/// `content` holds the content streams only a reference identifies (a page's `/Contents`, Type 3 +/// glyph procedures), besides form XObjects and appearance streams, which say so themselves. +pub(crate) fn exceeded(doc: &Document, objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { let mut found = Exceeded::default(); - for (r, o) in objs { + found.dict(doc.trailer(), 0); + for (_, o) in objs { if found.all() { - break; + return found; } 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); - } + } + let mut budget = DecodeBudget::new(MAX_CONTENT_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for page in pdfcraft_model::pages(doc) { + if found.all() || budget.remaining() == 0 { + return found; + } + let refs = contents(doc, &page.dict); + let mut joined = Vec::new(); + for r in &refs { + if let Object::Stream(s) = &*doc.get(*r) + && let Ok(data) = budget.decode(s) + { + joined.extend_from_slice(&data); + joined.push(b' '); } } + read.extend(refs); + found.content(&joined); + } + for (r, o) in objs { + if found.all() || budget.remaining() == 0 { + break; + } + if let Object::Stream(s) = &**o + && !read.contains(r) + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = budget.decode(s) + { + found.content(&data); + } } found } +/// A page's `/Contents` streams, in order. +fn contents(doc: &Document, page: &Dict) -> Vec { + match page.get(b"Contents") { + Some(Object::Ref(r)) => match &*doc.get(*r) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => vec![*r], + }, + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + _ => Vec::new(), + } +} + /// 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 { @@ -108,6 +157,7 @@ pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { 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)) + // A size past `f64` (corners near ±1e308) is as much too large as any other. + [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) }) } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 5adf411714..0255348361 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -521,6 +521,7 @@ fn implementation_limits_are_reported() { 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"]); + assert_eq!(limits(&doc("", &format!("/{name} Do"), 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()); @@ -528,3 +529,42 @@ fn implementation_limits_are_reported() { 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"); } + +/// 6.1.13 also reads the trailer, a page's `/Contents` streams joined (operands may end one +/// stream and their operator start the next, ISO 32000-1 7.8.2), values nested as deep as the +/// parser allows, and a page box whose size overflows to infinity. +#[test] +fn implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |data: &str| { + format!( + "<< /Length {} >> +stream +{data} +endstream", + data.len() + ) + }; + let page = |contents: &str, catalog_extra: &str, media: &str, trailer: &str| { + let (first, second) = contents.split_once('|').unwrap_or((contents, "")); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + &format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {media} >>"), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>", + &stream(first), + &stream(second), + ], + trailer, + ) + }; + let ok = "[0 0 200 200]"; + assert!(limits(&page("|", "", ok, "")).is_empty()); + assert_eq!(limits(&page("|", "", ok, "/Custom 2147483648")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); + let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&page("|", "", "[-1e308 0 1e308 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); +} From 8d948e1dd0e948ea7e35578d2e03e35194cbf229 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:16:15 +0800 Subject: [PATCH 3/5] =?UTF-8?q?M11:=206.1.13=20also=20reports=20reals=20pa?= =?UTF-8?q?st=20=C2=B13.403e38=20and=20CMaps=20mapping=20to=20CIDs=20above?= =?UTF-8?q?=2065535?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the clause. Table C.1 of ISO 32000-1 Annex C bounds real values at about ±3.403 × 10^38 and character identifiers at 65535. A real past that range (an integer token too long even for a real reads as one) is reported, and so is an embedded CMap of a Type 0 font, or one it uses through /UseCMap, whose begincidrange entry reaches past CID 65535 or whose begincidchar entry names one (ISO 32000-1 9.7.5.4). A CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded within one 16 MiB allowance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 8 ++++ crates/preflight/src/limits.rs | 68 ++++++++++++++++++++++++++++++++-- crates/preflight/src/tests.rs | 42 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 059fff4a36..40f0bb78d1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,6 +117,8 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "An integer is outside the 32-bit range", + "A real number is outside ±3.403 × 10³⁸", + "A CMap maps a character code to a CID above 65535", "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", @@ -525,9 +527,15 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } + if over.real { + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸", None, None, false); + } if over.string { issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); } + if limits::cid_over_limit(doc, &objs) { + issue("6.1.13", "A CMap maps a character code to a CID above 65535", None, None, false); + } if over.name { issue("6.1.13", "A name is longer than 127 bytes", None, None, false); } diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index 7930cb9bc4..ec9886dcf5 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -6,8 +6,9 @@ //! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read //! joined, as one content stream (an operator's operands may end one stream and the operator start //! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. -//! Character identifiers above 65535 (in a CMap) and the real-number range are not checked here, -//! nor an integer token too long for 64 bits, which the parser reads as a real. +//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so is an embedded CMap of a Type 0 font +//! (or one it uses through `/UseCMap`) that maps a code to a CID above 65535. An integer token too +//! long even for a real reads as one past that range, and is reported as a real. use std::collections::HashSet; @@ -16,6 +17,14 @@ use pdfcraft_cos::{DecodeBudget, 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 largest real value (approximate). +const MAX_REAL: f64 = 3.403e38; +/// Table C.1: the largest character identifier. +const MAX_CID: u64 = 65535; +/// The decoded CMap data, across all the document's embedded CMaps, the scan reads. +const MAX_CMAP_BYTES: usize = 16 << 20; +/// How many CMaps a `/UseCMap` chain is followed through. +const MAX_CMAP_CHAIN: usize = 8; /// Table C.1: the longest string, in bytes. const MAX_STRING: usize = 32767; /// Table C.1: the longest name, in bytes. @@ -35,13 +44,14 @@ const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"A #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Exceeded { pub(crate) int: bool, + pub(crate) real: bool, pub(crate) string: bool, pub(crate) name: bool, } impl Exceeded { fn all(self) -> bool { - self.int && self.string && self.name + self.int && self.real && self.string && self.name } fn name(&mut self, n: &[u8]) { @@ -55,6 +65,7 @@ impl Exceeded { } match o { Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL, Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, Object::Name(n) => self.name(n), Object::Array(a) => { @@ -64,7 +75,7 @@ impl Exceeded { } Object::Dict(d) => self.dict(d, depth), Object::Stream(s) => self.dict(&s.dict, depth), - Object::Null | Object::Bool(_) | Object::Real(_) | Object::Ref(_) => {} + Object::Null | Object::Bool(_) | Object::Ref(_) => {} } } @@ -161,3 +172,52 @@ pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) }) } + +/// Whether an embedded CMap of a Type 0 font among `objs`, or one it uses through `/UseCMap`, maps +/// a code to a CID above 65535: a `begincidrange` entry ` cid` reaches +/// `cid + high − low`, a `begincidchar` entry ` cid` reaches `cid` (ISO 32000-1 9.7.5.4). A +/// CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded +/// within one allowance for the document. +pub(crate) fn cid_over_limit(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> bool { + let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for (_, o) in objs { + let Some(d) = o.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Type0") { + continue; + } + let mut next = d.get(b"Encoding").and_then(Object::as_ref); + for _ in 0..MAX_CMAP_CHAIN { + let Some(r) = next.filter(|r| read.insert(*r)) else { break }; + if budget.remaining() == 0 { + return false; + } + let Object::Stream(cmap) = &*doc.get(r) else { break }; + if budget.decode(cmap).is_ok_and(|data| maps_past_limit(&data)) { + return true; + } + next = cmap.dict.get(b"UseCMap").and_then(Object::as_ref); + } + } + false +} + +/// Whether the CMap program `data` maps a code to a CID above [`MAX_CID`]. The entries of a +/// `begincidrange` … `endcidrange` block are the operands of its `endcidrange`, read as content is. +fn maps_past_limit(data: &[u8]) -> bool { + let value = |o: &Object| -> Option { + match o { + Object::String(s) if s.bytes.len() <= 4 => Some(s.bytes.iter().fold(0u64, |v, b| (v << 8) | u64::from(*b))), + Object::Int(i) => u64::try_from(*i).ok(), + _ => None, + } + }; + pdfcraft_content::ops(data).any(|op| match op.op.as_slice() { + b"endcidrange" => op.operands.as_chunks::<3>().0.iter().any(|[low, high, cid]| match (value(low), value(high), value(cid)) { + (Some(low), Some(high), Some(cid)) => cid.saturating_add(high.saturating_sub(low)) > MAX_CID, + _ => false, + }), + b"endcidchar" => op.operands.as_chunks::<2>().0.iter().any(|[_, cid]| value(cid).is_some_and(|cid| cid > MAX_CID)), + _ => false, + }) +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 0255348361..6893a42e2f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -566,5 +566,45 @@ endstream", assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); - assert_eq!(limits(&page("|", "", "[-1e308 0 1e308 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. + assert_eq!( + limits(&page("|", "", "[-1e308 0 1e308 200]", "")), + ["A real number is outside ±3.403 × 10³⁸", "Page 1 has a boundary smaller than 3 or larger than 14400 units"] + ); +} + +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸, and +/// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching +/// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). +#[test] +fn implementation_limits_cover_reals_and_cids() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = |catalog_extra: &str, cmap: &str, used: &str| { + let first = stream("/Type /CMap /CMapName /Test-A /UseCMap 6 0 R", cmap); + let second = stream("/Type /CMap /CMapName /Test-B", used); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F 4 0 R >> >> >>", + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding 5 0 R /DescendantFonts [] >>", + &first, + &second, + ], + "", + ) + }; + let range = |low: &str, high: &str, cid: u32| format!("1 begincidrange <{low}> <{high}> {cid} endcidrange"); + let near = format!("34{}.0", "0".repeat(37)); + let far = format!("4{}.0", "0".repeat(38)); + assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + let cid = ["A CMap maps a character code to a CID above 65535"]; + assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); + assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536"); + assert_eq!(limits(&doc("", "1 begincidchar <01> 70000 endcidchar", "")), cid); + assert_eq!(limits(&doc("", &range("0000", "00ff", 0), &range("3f00", "3fff", 65536))), cid, "through /UseCMap"); } From 6ebfada74d246957764c359954122c4d8baf6457 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:16:15 +0800 Subject: [PATCH 4/5] M11: parity: Verify PDF/A checks the implementation limits (6.1.13) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..cb5b9f1ff9 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/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/preflight/src/tests.rs::implementation_limits_are_reported", "crates/preflight/src/tests.rs::implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes", "crates/preflight/src/tests.rs::implementation_limits_cover_reals_and_cids"] +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, and the implementation limits of 6.1.13 (integers, reals, strings, names, CIDs and page boundaries, ISO 32000-1 Annex C). Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From bf20038b0bfe9da9864af6cf03476a05bcba6fd4 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:46:57 +0800 Subject: [PATCH 5/5] =?UTF-8?q?M11:=20PDF/A=20verify=20reports=20nonzero?= =?UTF-8?q?=20reals=20nearer=20zero=20than=20=C2=B11.175=20=C3=97=2010?= =?UTF-8?q?=E2=81=BB=C2=B3=E2=81=B8=20(6.1.13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF Association TechNote 0010 A018: PDF/A-2 does not allow the nonzero reals that ISO 32000-1 Table C.1 lets a reader round to zero. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 4 ++-- crates/preflight/src/limits.rs | 8 ++++++-- crates/preflight/src/tests.rs | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 40f0bb78d1..6004ff7fa7 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,7 +117,7 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "An integer is outside the 32-bit range", - "A real number is outside ±3.403 × 10³⁸", + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", "A CMap maps a character code to a CID above 65535", "A string is longer than 32767 bytes", "A name is longer than 127 bytes", @@ -528,7 +528,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } if over.real { - issue("6.1.13", "A real number is outside ±3.403 × 10³⁸", None, None, false); + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", None, None, false); } if over.string { issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index ec9886dcf5..9d986d98aa 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -6,7 +6,9 @@ //! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read //! joined, as one content stream (an operator's operands may end one stream and the operator start //! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. -//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so is an embedded CMap of a Type 0 font +//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so are nonzero ones nearer zero than +//! ±1.175 × 10⁻³⁸ (ISO 32000-1 Table C.1 lets a reader round them to zero; PDF/A-2 does not allow +//! them, PDF Association TechNote 0010 A018), and so is an embedded CMap of a Type 0 font //! (or one it uses through `/UseCMap`) that maps a code to a CID above 65535. An integer token too //! long even for a real reads as one past that range, and is reported as a real. @@ -19,6 +21,8 @@ const MIN_INT: i64 = i32::MIN as i64; const MAX_INT: i64 = i32::MAX as i64; /// Table C.1: the largest real value (approximate). const MAX_REAL: f64 = 3.403e38; +/// Table C.1: the nonzero real value closest to zero (approximate). +const MIN_REAL: f64 = 1.175e-38; /// Table C.1: the largest character identifier. const MAX_CID: u64 = 65535; /// The decoded CMap data, across all the document's embedded CMaps, the scan reads. @@ -65,7 +69,7 @@ impl Exceeded { } match o { Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), - Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL, + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL || (*r != 0.0 && r.abs() < MIN_REAL), Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, Object::Name(n) => self.name(n), Object::Array(a) => { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 6893a42e2f..bd861b3561 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -569,11 +569,15 @@ endstream", // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. assert_eq!( limits(&page("|", "", "[-1e308 0 1e308 200]", "")), - ["A real number is outside ±3.403 × 10³⁸", "Page 1 has a boundary smaller than 3 or larger than 14400 units"] + [ + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", + "Page 1 has a boundary smaller than 3 or larger than 14400 units" + ] ); } -/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸, and +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸ (or +/// nonzero and nearer zero than ±1.175 × 10⁻³⁸), and /// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching /// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). #[test] @@ -600,8 +604,15 @@ fn implementation_limits_cover_reals_and_cids() { let near = format!("34{}.0", "0".repeat(37)); let far = format!("4{}.0", "0".repeat(38)); assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); - assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); - assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + // A nonzero real nearer zero than ±1.175 × 10⁻³⁸ is not allowed either; zero and the + // smallest allowed magnitude are. + let tiny = format!("0.{}1", "0".repeat(39)); + let smallest = format!("0.{}1175", "0".repeat(37)); + assert!(limits(&doc(&format!("/Z 0.0 /M -0.0 /S {smallest} /T -{smallest}"), "", "")).is_empty(), "zero and the smallest real"); + assert_eq!(limits(&doc(&format!("/R {tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); let cid = ["A CMap maps a character code to a CID above 65535"]; assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536");