mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
1 parent
083b3aca44
commit
7c83ff748e
3 files changed
+174
No files matched your search
@@ -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<Issue> {
|
||||
}
|
||||
}
|
||||
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)));
|
||||
|
||||
@@ -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<Object>)], content: &HashSet<ObjRef>) -> 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<f64> = 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))
|
||||
})
|
||||
}
|
||||
@@ -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<String> { 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");
|
||||
}
|
||||
Reference in new issue
Block a user