Merge upstream/main into feat/callout-reshape-1152

This commit is contained in:
mtlcl410 committed 2026-11-09 11:43:25 +08:00
commit 8f1a820716
17 files changed
+3376 -60

No files matched your search

+1 -1
View File
@@ -1361,7 +1361,7 @@ pub fn tools() -> Vec<ToolDef> {
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, the file identifier). 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.")
+1 -1
View File
@@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};
pub use writer::{SaveOptions, civil_from_days, pdf_date, reachable_objects, serialize, write_full, write_incremental};
pub use writer::{SaveOptions, civil_from_days, new_file_id, pdf_date, reachable_objects, serialize, write_full, write_incremental};
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CosError {
+11 -3
View File
@@ -789,13 +789,21 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) {
}
}
/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4).
/// Keep any existing two-element `/ID` array as it is, even a malformed one such as `[<> <>]` or `[1 2]` (an ordinary
/// save preserves what it doesn't understand; Save as PDF/A replaces or completes it), or create one with
/// [`new_file_id`] when there is none or it isn't a two-element array.
fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) {
return;
}
trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed));
}
/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of
/// two equal 16-byte strings, the same for the same content and `seed`.
pub fn new_file_id(content: &[u8], seed: u64) -> Object {
// FNV-1a over a sample of the content and the seed: stable, dependency-free.
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed;
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed;
for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
@@ -804,7 +812,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
id.extend_from_slice(&h.to_be_bytes());
id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes());
let s = Object::String(PdfString { bytes: id, hex: true });
trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
Object::Array(vec![s.clone(), s])
}
/// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not
+19
View File
@@ -11870,6 +11870,25 @@ fn imported_bookmarks_undo_in_one_step() {
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"));
}
}
/// ISO 32000-2 §7.6.6, Table 25: a document whose only encryption is for its embedded files,
/// authorised when one is opened (`/AuthEvent /EFOpen`), opens without a password, lists its
/// attachment, and saves with the attachment still encrypted.
+235
View File
@@ -0,0 +1,235 @@
//! PDF/A-2 6.2.5, the extended graphics state: device-dependent settings a conforming file may not
//! rely on.
//!
//! - no transfer function: no `/TR` entry, and `/TR2` only as `/Default` (ISO 32000-1 10.4);
//! - no `/HTP` (halftone phase) entry, a PDF 1.2 key ISO 32000-1 no longer defines;
//! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones
//! a type 5 halftone holds for its colorants are checked the same way;
//! - a halftone's `/TransferFunction` only where one is needed: a type 5 halftone's component for
//! a non-primary colorant has one, and no other halftone does. The primary colour components
//! are those of the standard device spaces: Gray; Red, Green and Blue; Cyan, Magenta, Yellow
//! and Black (ISO 32000-1 10.5.5.6; ISO 32000-2 10.6.5.6). The `Default` component is left as
//! it is (ISO 32000-1 Table 130);
//! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70.
//!
//! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries
//! (`/Resources` of pages, forms, Type 3 fonts, patterns and annotation appearances, and an
//! interactive form's `/DR`), and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3,
//! Table 76). They are looked for in what the document reaches from its trailer, following
//! references, but not into private application data (`/PieceInfo`), so an object only that data
//! refers to isn't read. A map shared by reference is read once.
use std::collections::{BTreeSet, HashSet, VecDeque};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
/// How deep a direct object is followed: as deep as the parser nests them.
const MAX_DEPTH: u8 = 128;
/// The rendering intents of ISO 32000-1 Table 70.
const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"];
/// The entries of a type 5 halftone that aren't colorant halftones.
const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"];
/// The primary colour components of DeviceGray, DeviceRGB and DeviceCMYK, whose halftones take
/// the graphics state's transfer function.
const PRIMARY: [&[u8]; 8] = [b"Gray", b"Red", b"Green", b"Blue", b"Cyan", b"Magenta", b"Yellow", b"Black"];
/// The entries that hold resource dictionaries.
const RESOURCES: [&[u8]; 2] = [b"Resources", b"DR"];
/// What the graphics states of a document break of 6.2.5.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Findings {
/// A graphics state has a `/TR` entry.
pub(crate) transfer: bool,
/// A graphics state has a `/TR2` other than `/Default`.
pub(crate) transfer2: bool,
/// A graphics state has an `/HTP`.
pub(crate) phase: bool,
/// Halftone types other than 1 and 5, in order.
pub(crate) halftone_types: BTreeSet<i64>,
/// A halftone's `/HalftoneType` is missing or isn't an integer.
pub(crate) untyped: bool,
/// A halftone has a `/HalftoneName`.
pub(crate) halftone_name: bool,
/// A halftone that may not have a `/TransferFunction` has one.
pub(crate) halftone_transfer: bool,
/// Non-primary colorants whose halftone has no `/TransferFunction`, in name order.
pub(crate) missing_transfer: BTreeSet<String>,
/// Rendering intents outside Table 70, in name order.
pub(crate) intents: BTreeSet<String>,
/// A rendering intent isn't a name.
pub(crate) intent_not_name: bool,
}
/// The 6.2.5 findings over what `doc` reaches from its trailer.
pub(crate) fn check(doc: &Document) -> Findings {
scan(doc).findings
}
/// How many objects [`check`] reads.
#[cfg(test)]
pub(crate) fn reached(doc: &Document) -> usize {
scan(doc).reached.len()
}
fn scan(doc: &Document) -> Scan<'_> {
let mut scan = Scan { doc, read: HashSet::new(), reached: HashSet::new(), queue: VecDeque::new(), findings: Findings::default() };
scan.walk(&Object::Dict(doc.trailer().clone()), 0);
// References are followed from a queue, not by recursion: a chain of them can be as long as
// the file.
while let Some(r) = scan.queue.pop_front() {
let o = doc.get(r);
scan.walk(&o, 0);
}
scan
}
struct Scan<'a> {
doc: &'a Document,
/// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by object number.
read: HashSet<u32>,
/// Object numbers reached so far, queued or walked. Both sets go by number, as
/// [`Document::get`] does: references differing only in generation reach the same object.
reached: HashSet<u32>,
queue: VecDeque<ObjRef>,
findings: Findings,
}
impl Scan<'_> {
fn walk(&mut self, o: &Object, depth: u8) {
if depth > MAX_DEPTH {
return;
}
let d = match o {
Object::Ref(r) => {
if self.reached.insert(r.num) {
self.queue.push_back(*r);
}
return;
}
Object::Array(a) => {
for v in a {
self.walk(v, depth.saturating_add(1));
}
return;
}
Object::Dict(d) => d,
Object::Stream(s) => &s.dict,
_ => return,
};
self.holder(d);
for (k, v) in d.iter() {
if k.as_slice() != b"PieceInfo" {
self.walk(v, depth.saturating_add(1));
}
}
}
/// Whether `o` is a reference read before; marks it read.
fn read_before(&mut self, o: &Object) -> bool {
matches!(o, Object::Ref(r) if !self.read.insert(r.num))
}
/// A dictionary that holds graphics states: through its resources, or as a shading pattern.
fn holder(&mut self, d: &Dict) {
for key in RESOURCES {
if let Some(resources) = d.get(key) {
self.resources(resources);
}
}
if d.get(b"PatternType").is_some()
&& let Some(gs) = d.get(b"ExtGState")
&& !self.read_before(gs)
&& let Some(gs) = self.doc.resolve(gs).as_dict()
{
self.gstate(gs);
}
}
fn resources(&mut self, resources: &Object) {
if self.read_before(resources) {
return;
}
let resources = self.doc.resolve(resources);
let Some(states) = resources.as_dict().and_then(|r| r.get(b"ExtGState")) else { return };
if self.read_before(states) {
return;
}
let states = self.doc.resolve(states);
let Some(states) = states.as_dict() else { return };
for (_, gs) in states.iter() {
if !self.read_before(gs)
&& let Some(gs) = self.doc.resolve(gs).as_dict()
{
self.gstate(gs);
}
}
}
/// `key` of `d`, resolved, unless it is missing or null (ISO 32000-1 7.3.9).
fn present(&self, d: &Dict, key: &[u8]) -> Option<std::sync::Arc<Object>> {
d.get(key).map(|v| self.doc.resolve(v)).filter(|v| !matches!(**v, Object::Null))
}
fn gstate(&mut self, gs: &Dict) {
if self.present(gs, b"TR").is_some() {
self.findings.transfer = true;
}
if self.present(gs, b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) {
self.findings.transfer2 = true;
}
if self.present(gs, b"HTP").is_some() {
self.findings.phase = true;
}
if let Some(ht) = self.present(gs, b"HT")
&& let Some(ht) = ht.as_dict()
{
self.halftone(ht, None);
}
if let Some(ri) = self.present(gs, b"RI") {
match ri.as_name() {
Some(ri) if !INTENTS.contains(&ri) => {
self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned());
}
Some(_) => {}
None => self.findings.intent_not_name = true,
}
}
}
/// A halftone dictionary or stream: the graphics state's own (`colorant` is `None`), or the
/// one a type 5 halftone holds for `colorant`.
fn halftone(&mut self, ht: &Dict, colorant: Option<&[u8]>) {
// An integer (ISO 32000-1 Table 128), not a real of the same value.
let kind = self.present(ht, b"HalftoneType").and_then(|t| if let Object::Int(t) = *t { Some(t) } else { None });
match kind {
None => self.findings.untyped = true,
Some(1 | 5) => {}
Some(kind) => {
self.findings.halftone_types.insert(kind);
}
}
if self.present(ht, b"HalftoneName").is_some() {
self.findings.halftone_name = true;
}
let transfer = self.present(ht, b"TransferFunction").is_some();
match colorant {
Some(b"Default") => {}
Some(c) if !PRIMARY.contains(&c) => {
if !transfer {
self.findings.missing_transfer.insert(String::from_utf8_lossy(c).into_owned());
}
}
_ => self.findings.halftone_transfer |= transfer,
}
if colorant.is_none() && kind == Some(5) {
for (k, v) in ht.iter() {
if HALFTONE_KEYS.contains(&k.as_slice()) {
continue;
}
if let Some(component) = self.doc.resolve(v).as_dict() {
self.halftone(component, Some(k));
}
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//! 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,
}
/// 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();
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())
}
+400 -41
View File
@@ -10,7 +10,9 @@
//! - every font embedded;
//! - annotations: allowed types, printable, not hidden, with appearance streams;
//! - actions: none of the forbidden kinds (JavaScript, Launch, Sound, Movie, ResetForm,
//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView); no additional actions on the
//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView, and the PDF 1.2 SetState and
//! NOP that ISO 32000-1 no longer defines); Named actions only for NextPage, PrevPage,
//! FirstPage and LastPage (ISO 32000-1 12.6.4.11, Table 212); no additional actions on the
//! catalog, pages, fields or widgets; `NeedAppearances` not true;
//! - images: no `Interpolate`, no `Alternates`, no OPI; no PostScript XObjects;
//! - embedded files: PDF/A-2 only allows embedded PDF/A files; PDF/A-3 needs `AFRelationship`.
@@ -21,8 +23,15 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod gstate;
mod header;
pub mod icc;
mod limits;
mod spot;
mod trailer;
mod transparency;
mod xmp;
mod xmp_extension;
mod xmp_types;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
@@ -153,14 +162,46 @@ pub const WORDINGS: &[&str] = &[
"The title in the metadata and the document information differ",
"The XMP property {property} in metadata stream {object} should have the value type {type}",
"The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}",
"The XMP extension schema {schema} in metadata stream {object} has no {entry} entry",
"The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry",
"The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry",
"The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry",
"The XMP extension schema container in metadata stream {object} holds no rdf:Bag",
"The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag",
"Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}",
"Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}",
// 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 trailer has no file identifier (ID)",
"The file identifier (ID) in the trailer isn't two non-empty strings",
"The document is encrypted",
"An integer is outside the 32-bit range",
"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",
"Page {} has a boundary smaller than 3 or larger than 14400 units",
"Device colour is used, but there is no PDF/A output intent",
"Page {} has transparency but no device-independent blending colour space or PDF/A output intent",
"A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define",
"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",
"DeviceRGB is used, but the PDF/A output intent isn't an RGB profile",
"Separation colour spaces named {} differ in their alternate space or tint transform",
"The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary",
"The document has additional actions (/AA)",
"The document has document-level JavaScript",
"A graphics state has a transfer function (TR)",
"A graphics state has a transfer function (TR2) other than Default",
"A graphics state has a halftone phase (HTP)",
"A halftone has type {}: only types 1 and 5 are allowed",
"A halftone's HalftoneType is missing or isn't an integer",
"A halftone has a HalftoneName",
"A halftone has a transfer function, which only a non-primary colorant's halftone may have",
"The halftone for the colorant {} has no transfer function",
"A graphics state has the rendering intent {}, which isn't a standard one",
"A graphics state has a rendering intent that isn't a name",
"The form asks viewers to make appearances (NeedAppearances)",
// Pages, actions, annotations and fields.
"Page {} has additional actions",
@@ -192,8 +233,23 @@ pub struct Declared {
pub output_intents: Vec<String>,
}
const FORBIDDEN_ACTIONS: &[&[u8]] =
&[b"JavaScript", b"Launch", b"Sound", b"Movie", b"ResetForm", b"ImportData", b"Hide", b"SetOCGState", b"Rendition", b"Trans", b"GoTo3DView"];
const FORBIDDEN_ACTIONS: &[&[u8]] = &[
b"JavaScript",
b"Launch",
b"Sound",
b"Movie",
b"ResetForm",
b"ImportData",
b"Hide",
b"SetOCGState",
b"Rendition",
b"Trans",
b"GoTo3DView",
b"SetState",
b"NOP",
];
/// The named actions of ISO 32000-1 Table 212, the only ones a Named action may name.
const NAMED_ACTIONS: &[&[u8]] = &[b"NextPage", b"PrevPage", b"FirstPage", b"LastPage"];
const FORBIDDEN_ANNOTS: &[&[u8]] = &[b"Sound", b"Movie", b"Screen", b"3D", b"RichMedia"];
fn catalog(doc: &Document) -> Dict {
@@ -250,6 +306,19 @@ fn dict_of(o: &Object) -> Option<&Dict> {
}
}
/// Whether the document has a PDF/A output intent whose `/DestOutputProfile` is a stream: one with
/// a null or missing profile describes no colour, so it doesn't stand in for a blending colour
/// space (6.2.10).
fn pdfa_intent_has_profile(doc: &Document) -> bool {
let cat = catalog(doc);
let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
list.iter().any(|oi| {
let oi = doc.resolve(oi);
let Some(d) = oi.as_dict() else { return false };
d.name(b"S") == Some(b"GTS_PDFA1") && d.get(b"DestOutputProfile").is_some_and(|p| matches!(&*doc.resolve(p), Object::Stream(_)))
})
}
fn has_pdfa_intent(doc: &Document) -> bool {
pdfa_intent_profile(doc).is_some()
}
@@ -369,6 +438,37 @@ fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
out
}
/// The fonts the document draws with: those a resource dictionary names (`/Resources`, of a page or
/// a page tree node, a form XObject or an annotation's appearance stream, a pattern or a Type 3
/// font). PDF/A asks that every font "used for rendering" be embedded (ISO 19005-2, 6.2.11.4.1). A
/// font that only the interactive form's default resources hold (`/AcroForm /DR`, ISO 32000-1
/// 12.7.2) draws nothing, so it needn't be: an appearance stream that does draw with it names it in
/// its own `/Resources`.
fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> std::collections::HashSet<ObjRef> {
let mut out = std::collections::HashSet::new();
for (_, o) in objs {
let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue };
let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue };
if let Some(fonts) = fonts.as_dict() {
out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref()).map(|r| font_object(doc, r)));
}
}
out
}
/// How many references [`font_object`] follows, as the document's own `resolve` does.
const MAX_REFERENCE_HOPS: usize = 32;
/// The object a font entry's reference `r` ends at: an object can be nothing but a reference to
/// another (ISO 32000-1 7.3.10), and the font dictionary is the last one in that chain.
fn font_object(doc: &Document, mut r: ObjRef) -> ObjRef {
for _ in 0..MAX_REFERENCE_HOPS {
let Object::Ref(next) = &*doc.get(r) else { break };
r = *next;
}
r
}
/// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a
/// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out
/// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content.
@@ -486,7 +586,48 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
let message = fill(pattern, None, &values);
push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable });
};
let cat = catalog(doc);
check_identification(doc, level, &mut issue);
let objs = objects(doc);
check_xmp_packets(&objs, &located_issue);
check_output_intent(doc, &objs, &mut issue);
check_catalog_actions(doc, &mut issue);
// Pages: annotations and page actions.
let pages = pdfcraft_model::pages(doc);
// Each annotation's page: the first page that lists it.
let mut annot_pages: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
for (i, p) in pages.iter().enumerate() {
if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a))
&& let Some(a) = a.as_array()
{
#[cfg(test)]
ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len()));
for r in a.iter().filter_map(Object::as_ref) {
annot_pages.entry(r).or_insert(i);
}
}
}
let page_of = |r: ObjRef| annot_pages.get(&r).copied();
for (i, p) in pages.iter().enumerate() {
if p.dict.get(b"AA").is_some() {
issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true);
}
}
check_transparency(doc, &pages, &mut issue);
let drawing = drawing_fonts(doc, &objs);
check_objects(doc, level, &objs, &drawing, &page_of, &mut issue);
check_limits(doc, &objs, &pages, &mut issue);
check_spots(doc, &objs, &mut issue);
check_graphics_states(doc, &mut issue);
// One message per kind of problem is enough for repeated ones (as preflight summaries do).
let mut out = out.into_inner();
let mut seen = std::collections::HashSet::new();
out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page)));
out
}
/// 6.1.2, 6.1.3 and 6.6: identification and metadata, the file header, the file identifier and
/// encryption.
fn check_identification(doc: &Document, level: Level, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
// 6.1.3 / 6.6: identification and metadata.
match metadata(doc) {
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
@@ -503,16 +644,37 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
}
}
// 6.1.3: encryption.
// 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: the file identifier and encryption.
match trailer::check(doc) {
Some(trailer::IdProblem::Missing) => issue("6.1.3", "The trailer has no file identifier (ID)", None, None, true),
Some(trailer::IdProblem::Malformed) => {
issue("6.1.3", "The file identifier (ID) in the trailer isn't two non-empty strings", None, None, true)
}
None => {}
}
if doc.trailer().get(b"Encrypt").is_some() {
issue("6.1.3", "The document is encrypted", None, None, true);
}
// 6.2.2: output intent when device colour is used.
let objs = objects(doc);
// 6.6.2.3.1: the value types of predefined XMP properties, in every metadata stream.
let mut mistyped = Vec::new();
xmp_types::for_each_packet(&objs, |object, packet| {
}
/// 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in
/// every metadata stream.
fn check_xmp_packets(
objs: &[(ObjRef, std::sync::Arc<Object>)],
located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool),
) {
let (mut mistyped, mut extension) = (Vec::new(), xmp_extension::Findings::default());
xmp_types::for_each_packet(objs, |object, packet| {
mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty)));
extension.add(object, packet);
});
for (object, name, written, ty) in mistyped {
let mut values = vec![("{property}", name.clone()), ("{object}", object.to_string()), ("{type}", ty)];
@@ -535,7 +697,104 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
);
}
}
let used = uses_device_colour(doc, &objs);
report_xmp_extension(extension, located_issue);
}
/// 6.6.2.3.3: what the extension schema descriptions of the metadata streams lack.
fn report_xmp_extension(
extension: xmp_extension::Findings,
located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool),
) {
for m in extension.missing {
let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)];
match (m.item, m.empty) {
(None, false) => located_issue(
"6.6.2.3.3",
"The XMP extension schema {schema} in metadata stream {object} has no {entry} entry",
values,
m.object,
false,
),
(None, true) => located_issue(
"6.6.2.3.3",
"The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry",
values,
m.object,
false,
),
(Some(item), false) => {
values.push(("{item}", item));
located_issue(
"6.6.2.3.3",
"The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry",
values,
m.object,
false,
);
}
(Some(item), true) => {
values.push(("{item}", item));
located_issue(
"6.6.2.3.3",
"The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry",
values,
m.object,
false,
);
}
}
}
for c in extension.not_bag {
let object = c.object;
if c.found.is_empty() {
located_issue(
"6.6.2.3.3",
"The XMP extension schema container in metadata stream {object} holds no rdf:Bag",
vec![("{object}", object.to_string())],
object,
false,
);
} else {
let values = vec![("{object}", object.to_string()), ("{found}", c.found)];
located_issue(
"6.6.2.3.3",
"The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag",
values,
object,
false,
);
}
}
for p in extension.prefixes {
let mut values = vec![("{object}", p.object.to_string()), ("{namespace}", p.namespace.to_owned()), ("{prefix}", p.own.to_owned())];
if p.written.is_empty() {
located_issue(
"6.6.2.3.3",
"Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}",
values,
p.object,
false,
);
} else {
values.push(("{written}", p.written));
located_issue(
"6.6.2.3.3",
"Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}",
values,
p.object,
false,
);
}
}
}
/// 6.2.3 and 6.2.4.3: the output intent device colour needs.
fn check_output_intent(
doc: &Document,
objs: &[(ObjRef, std::sync::Arc<Object>)],
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
let used = uses_device_colour(doc, objs);
match pdfa_intent_profile(doc) {
None if used.any() => {
issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk);
@@ -554,7 +813,11 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
None => {}
}
}
fn check_catalog_actions(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
// 6.1.3 / 6.6.1: catalog actions.
let cat = catalog(doc);
if cat.get(b"AA").is_some() {
issue("6.5.2", "The document has additional actions (/AA)", None, None, true);
}
@@ -568,61 +831,137 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
{
issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)", None, None, true);
}
// Pages: annotations and page actions.
let pages = pdfcraft_model::pages(doc);
// Each annotation's page: the first page that lists it.
let mut annot_pages: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
}
fn check_transparency(
doc: &Document,
pages: &[pdfcraft_model::Page],
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
// 6.2.10: blend modes are those ISO 32000-1 defines, and without a PDF/A output intent (one with
// a profile) a page with transparency names a blending colour space.
let intent = pdfa_intent_has_profile(doc);
// Pages share resources, forms and fonts: what one page found under them, the next takes as is.
let mut shared = transparency::Shared::default();
for (i, p) in pages.iter().enumerate() {
if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a))
&& let Some(a) = a.as_array()
{
#[cfg(test)]
ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len()));
for r in a.iter().filter_map(Object::as_ref) {
annot_pages.entry(r).or_insert(i);
}
let painting = transparency::page_painting(doc, &p.dict, &mut shared);
for name in painting.unknown_blend_modes {
issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), Some(i), false);
}
if painting.transparency && !intent && !transparency::page_names_blending_space(doc, &p.dict) {
let n = Some((i + 1).to_string());
issue("6.2.10", "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", n, Some(i), false);
}
}
let page_of = |r: ObjRef| annot_pages.get(&r).copied();
}
fn check_limits(
doc: &Document,
objs: &[(ObjRef, std::sync::Arc<Object>)],
pages: &[pdfcraft_model::Page],
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
// 6.1.13: the implementation limits of ISO 32000-1 Annex C.
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);
}
if over.real {
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);
}
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);
}
for (i, p) in pages.iter().enumerate() {
if p.dict.get(b"AA").is_some() {
issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true);
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);
}
}
check_objects(doc, level, &objs, &page_of, &mut issue);
// One message per kind of problem is enough for repeated ones (as preflight summaries do).
let mut out = out.into_inner();
let mut seen = std::collections::HashSet::new();
out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page)));
out
}
fn check_spots(
doc: &Document,
objs: &[(ObjRef, std::sync::Arc<Object>)],
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
// 6.2.4.4: Separation and DeviceN colour spaces.
let spots = spot::check(doc, objs);
for name in spots.differing {
issue("6.2.4.4", "Separation colour spaces named {} differ in their alternate space or tint transform", Some(name), None, false);
}
for name in spots.undefined {
issue("6.2.4.4", "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", Some(name), None, false);
}
}
fn check_graphics_states(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
// 6.2.5: the extended graphics state.
let gs = gstate::check(doc);
if gs.transfer {
issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false);
}
if gs.transfer2 {
issue("6.2.5", "A graphics state has a transfer function (TR2) other than Default", None, None, false);
}
if gs.phase {
issue("6.2.5", "A graphics state has a halftone phase (HTP)", None, None, false);
}
for kind in gs.halftone_types {
issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false);
}
if gs.untyped {
issue("6.2.5", "A halftone's HalftoneType is missing or isn't an integer", None, None, false);
}
if gs.halftone_name {
issue("6.2.5", "A halftone has a HalftoneName", None, None, false);
}
if gs.halftone_transfer {
issue("6.2.5", "A halftone has a transfer function, which only a non-primary colorant's halftone may have", None, None, false);
}
for colorant in gs.missing_transfer {
issue("6.2.5", "The halftone for the colorant {} has no transfer function", Some(colorant), None, false);
}
for intent in gs.intents {
issue("6.2.5", "A graphics state has the rendering intent {}, which isn't a standard one", Some(intent), None, false);
}
if gs.intent_not_name {
issue("6.2.5", "A graphics state has a rendering intent that isn't a name", None, None, false);
}
}
fn check_objects(
doc: &Document,
level: Level,
objects: &[(ObjRef, std::sync::Arc<Object>)],
drawing: &std::collections::HashSet<ObjRef>,
page_of: &impl Fn(ObjRef) -> Option<usize>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
for (r, o) in objects {
let Some(d) = dict_of(o) else { continue };
check_one_object(doc, level, *r, d, o, page_of, issue);
check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue);
}
}
fn check_one_object(
doc: &Document,
level: Level,
reference: ObjRef,
dict: &Dict,
object: &Object,
page_of: &impl Fn(ObjRef) -> Option<usize>,
draws: bool,
page: Option<usize>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
let ty = dict.name(b"Type");
let subtype = dict.name(b"Subtype");
check_actions(doc, dict, issue);
if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) {
if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) {
// A forbidden annotation stops every later check for its object, as the scan did before
// it was split into helpers: the rest of its own checks and the form field, font, stream
// and embedded file below all came after that `continue`.
@@ -631,7 +970,8 @@ fn check_one_object(
if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") {
issue("6.5.2", "A form field has additional actions", None, None, true);
}
if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() {
// A Type 0 font's descendant is checked through it.
if draws && (ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some()) {
check_font(doc, dict, issue);
}
if let Object::Stream(stream) = object {
@@ -829,10 +1169,8 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec
if *read > MAX_ACTION_CHAIN {
return Some("too deeply chained".into());
}
if let Some(s) = d.name(b"S")
&& FORBIDDEN_ACTIONS.contains(&s)
{
return Some(String::from_utf8_lossy(s).into_owned());
if let Some(s) = action_forbidden(doc, d) {
return Some(s);
}
match d.get(b"Next") {
Some(Object::Dict(next)) => stack.push(next),
@@ -849,6 +1187,24 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec
None
}
/// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named
/// action for anything but the four of [`NAMED_ACTIONS`]. The type and the name may each be given
/// directly or by reference.
fn action_forbidden(doc: &Document, d: &Dict) -> Option<String> {
let s = doc.resolve(d.get(b"S")?);
let s = s.as_name()?;
if FORBIDDEN_ACTIONS.contains(&s) {
return Some(String::from_utf8_lossy(s).into_owned());
}
let n = d.get(b"N").map(|n| doc.resolve(n));
match n.as_deref().and_then(Object::as_name) {
_ if s != b"Named" => None,
Some(n) if NAMED_ACTIONS.contains(&n) => None,
Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))),
None => Some("Named".into()),
}
}
/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing.
fn forbidden(doc: &Document, o: &Object) -> bool {
match &*doc.resolve(o) {
@@ -874,6 +1230,9 @@ 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));
fixed.extend(trailer::fix(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);
+227
View File
@@ -0,0 +1,227 @@
//! 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 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.
//! 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.
use std::collections::HashSet;
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 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.
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.
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): 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"];
/// Which limits a value went past.
#[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.real && 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::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) => {
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::Ref(_) => {}
}
}
/// 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);
self.value(v, depth.saturating_add(1));
}
}
}
/// 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<Object>)], content: &HashSet<ObjRef>) -> Exceeded {
let mut found = Exceeded::default();
found.dict(doc.trailer(), 0);
for (_, o) in objs {
if found.all() {
return found;
}
found.value(o, 0);
}
let mut budget = DecodeBudget::new(MAX_CONTENT_BYTES, 0);
let mut read: HashSet<ObjRef> = 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<ObjRef> {
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 {
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());
// 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))
})
}
/// 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 `<low> <high> cid` reaches
/// `cid + high − low`, a `begincidchar` entry `<code> 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<Object>)]) -> bool {
let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0);
let mut read: HashSet<ObjRef> = 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<u64> {
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,
})
}
+212
View File
@@ -0,0 +1,212 @@
//! PDF/A-2 6.2.4.4, Separation and DeviceN colour spaces:
//!
//! - Separation spaces of the same colorant name agree on their alternate space and tint
//! transform, also where a DeviceN space's `/Colorants` dictionary holds them. They are compared
//! as objects, whether stored directly or by reference, at any depth (ISO 32000-1 7.3.10);
//! - a DeviceN space names each spot colorant it uses in the `/Colorants` dictionary of its
//! attributes (ISO 32000-1 8.6.6.5, Table 71); a null or missing entry defines nothing. The
//! process colorants (`Cyan`, `Magenta`, `Yellow`, `Black`, and those an NChannel space's
//! `/Process` lists) and `None` are not spot colorants.
//!
//! Colour spaces are found wherever they are written: in resource dictionaries, images, shadings
//! and other colour spaces, direct or by reference.
use std::collections::{BTreeSet, HashMap, HashSet};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
/// 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;
/// How deep two definitions are compared, references followed.
const MAX_COMPARE_DEPTH: u8 = 32;
/// How many object pairs the comparisons of one document visit in all. Past it, a comparison
/// claims no difference.
const MAX_COMPARE_STEPS: u32 = 1 << 20;
/// The decoded bytes the stream comparisons of one document may spend in all.
const MAX_COMPARE_BYTES: usize = 64 << 20;
/// The stream entries that say how the data is stored, not what it is.
const ENCODING: [&[u8]; 3] = [b"Length", b"Filter", b"DecodeParms"];
/// The colorants of DeviceCMYK, which a DeviceN space may name without defining them.
const PROCESS: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"];
/// What the colour spaces of a document break of 6.2.4.4.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Findings {
/// Colorant names whose Separation spaces differ, in name order.
pub(crate) differing: BTreeSet<String>,
/// Spot colorants a DeviceN space uses without a `/Colorants` entry, in name order.
pub(crate) undefined: BTreeSet<String>,
}
/// The 6.2.4.4 findings over the indirect objects `objs`.
pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> Findings {
let compare = Compare { doc, equal: HashSet::new(), steps: MAX_COMPARE_STEPS, budget: DecodeBudget::new(MAX_COMPARE_BYTES, 0) };
let mut scan = Scan { doc, compare, separations: HashMap::new(), findings: Findings::default() };
for (_, o) in objs {
scan.walk(o, 0);
}
scan.findings
}
struct Scan<'a> {
doc: &'a Document,
compare: Compare<'a>,
/// Each colorant name's first Separation space: its alternate space and tint transform.
separations: HashMap<Vec<u8>, (Object, Object)>,
findings: Findings,
}
impl Scan<'_> {
fn walk(&mut self, o: &Object, depth: u8) {
if depth > MAX_DEPTH {
return;
}
match o {
Object::Array(a) => {
match a.first().and_then(Object::as_name) {
Some(b"Separation") => self.separation(a),
Some(b"DeviceN") => self.devicen(a),
_ => {}
}
for v in a {
self.walk(v, depth.saturating_add(1));
}
}
Object::Dict(d) => {
for (_, v) in d.iter() {
self.walk(v, depth.saturating_add(1));
}
}
Object::Stream(s) => {
for (_, v) in s.dict.iter() {
self.walk(v, depth.saturating_add(1));
}
}
_ => {}
}
}
/// `[/Separation name alternateSpace tintTransform]`: the same name must keep the same
/// alternate space and tint transform.
fn separation(&mut self, a: &[Object]) {
let [_, name, alternate, tint, ..] = a else { return };
let name = self.doc.resolve(name);
let Some(name) = name.as_name() else { return };
let this = (alternate.clone(), tint.clone());
match self.separations.get(name) {
Some((alt, fun)) => {
if !self.compare.same(alt, &this.0, 0) || !self.compare.same(fun, &this.1, 0) {
self.findings.differing.insert(String::from_utf8_lossy(name).into_owned());
}
}
None => {
self.separations.insert(name.to_vec(), this);
}
}
}
/// `[/DeviceN names alternateSpace tintTransform attributes]`: each spot colorant in `names`
/// needs an entry in the attributes' `/Colorants`.
fn devicen(&mut self, a: &[Object]) {
let Some(names) = a.get(1).map(|n| self.doc.resolve(n)) else { return };
let Some(names) = names.as_array() else { return };
let attributes = a.get(4).map(|d| self.doc.resolve(d));
let attributes = attributes.as_deref().and_then(Object::as_dict);
let colorants = attributes.and_then(|d| d.get(b"Colorants")).map(|c| self.doc.resolve(c));
let colorants = colorants.as_deref().and_then(Object::as_dict);
let nchannel = attributes.and_then(|d| d.get(b"Subtype")).map(|t| self.doc.resolve(t)).is_some_and(|t| t.as_name() == Some(b"NChannel"));
let process = attributes
.filter(|_| nchannel)
.and_then(|d| d.get(b"Process"))
.map(|p| self.doc.resolve(p))
.and_then(|p| p.as_dict().and_then(|p| p.get(b"Components")).map(|c| self.doc.resolve(c)));
let process: Vec<Vec<u8>> = process
.as_deref()
.and_then(Object::as_array)
.map(|c| c.iter().filter_map(|n| self.doc.resolve(n).as_name().map(<[u8]>::to_vec)).collect())
.unwrap_or_default();
for n in names.iter().map(|n| self.doc.resolve(n)) {
let Some(n) = n.as_name() else { continue };
let spot = n != b"None" && !PROCESS.contains(&n) && !process.iter().any(|p| p == n);
// Each entry is a Separation space for the colorant it is keyed by (ISO 32000-1 8.6.6.5,
// Table 71); a null entry, or one referring to no object, defines nothing (7.3.9).
let defined = colorants.and_then(|c| c.get(n)).is_some_and(|e| self.separation_named(e, n));
if spot && !defined {
self.findings.undefined.insert(String::from_utf8_lossy(n).into_owned());
}
}
}
/// Whether `o` is `[/Separation name ...]` for the colorant `name`.
fn separation_named(&self, o: &Object, name: &[u8]) -> bool {
let o = self.doc.resolve(o);
let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false };
kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name)
}
}
/// Compares definitions, remembering which reference pairs it found equal, so a structure shared
/// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`].
struct Compare<'a> {
doc: &'a Document,
/// Reference pairs compared equal. A depth cut-off only ever answers "different", so an
/// equal answer holds at any depth.
equal: HashSet<(ObjRef, ObjRef)>,
steps: u32,
budget: DecodeBudget,
}
impl Compare<'_> {
/// Whether `a` and `b` are the same object, wherever their parts are stored: references are
/// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object
/// numbers are the same, and streams compare by their decoded data. Once the steps or the
/// decode allowance run out, nothing more is called different.
fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool {
let pair = match (a, b) {
(Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true,
(Object::Ref(x), Object::Ref(y)) => Some((*x, *y)),
_ => None,
};
let Some(steps) = self.steps.checked_sub(1) else { return true };
self.steps = steps;
if depth > MAX_COMPARE_DEPTH {
return false;
}
let (a, b) = (self.doc.resolve(a), self.doc.resolve(b));
let deeper = depth.saturating_add(1);
let same = match (&*a, &*b) {
(Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)),
(Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper),
(Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper),
(x, y) => x == y,
};
if same && let Some(pair) = pair {
self.equal.insert(pair);
}
same
}
/// Whether two dictionaries hold the same entries, those keyed in `skip` aside.
fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool {
let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count();
kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth)))
}
/// Whether two streams hold the same data, however each is encoded. Data that can't be
/// decoded within the allowance is not called different.
fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool {
let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) {
(None, None) => true,
(Some(x), Some(y)) => c.same(x, y, depth),
_ => false,
};
if a.raw == b.raw && stored_alike(self, b"Filter") && stored_alike(self, b"DecodeParms") {
return true;
}
match (self.budget.decode(a), self.budget.decode(b)) {
(Ok(x), Ok(y)) => x == y,
_ => true,
}
}
}
File diff suppressed because it is too large. Load diff
+55
View File
@@ -0,0 +1,55 @@
//! PDF/A-2 6.1.3, the file trailer: it has an `/ID`, a file identifier as ISO 32000-1 14.4
//! defines it, an array of two byte strings.
//!
//! The trailer read is the one the file's last `startxref` points to: the latest update's, or a
//! linearized file's first-page trailer (ISO 32000-1 F.3.4). Trailers earlier in the chain don't
//! stand in for it.
use pdfcraft_cos::{Document, Object};
/// The longest string a conforming file may hold (ISO 32000-1 Annex C, Table C.1).
const MAX_STRING: usize = 32767;
/// What is wrong with the file identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IdProblem {
/// The trailer has no `/ID`, or a null one.
Missing,
/// The `/ID` isn't an array of two non-empty strings.
Malformed,
}
/// What 6.1.3 finds wrong with the file identifier of `doc`, if anything.
pub(crate) fn check(doc: &Document) -> Option<IdProblem> {
let id = doc.trailer().get(b"ID").map(|id| doc.resolve(id)).filter(|id| !matches!(**id, Object::Null));
let Some(id) = id else { return Some(IdProblem::Missing) };
let strings = |parts: &[Object]| parts.iter().all(|p| doc.resolve(p).as_string().is_some_and(|s| !s.bytes.is_empty()));
match id.as_array() {
Some(parts) if parts.len() == 2 && strings(parts) => None,
_ => Some(IdProblem::Malformed),
}
}
/// Gives `doc` a file identifier when it lacks a good one; what it fixed, if anything. A first
/// string a conforming file can keep (non-empty, within [`MAX_STRING`]) is the permanent
/// identifier (ISO 32000-1 14.4): it is kept, and only the second is made afresh. Otherwise both
/// are new, and the report says the identifier was added rather than completed.
pub(crate) fn fix(doc: &mut Document) -> Option<String> {
check(doc)?;
let Object::Array(mut id) = pdfcraft_cos::new_file_id(doc.bytes(), 0) else { return None };
let permanent = doc
.trailer()
.get(b"ID")
.map(|old| doc.resolve(old))
.and_then(|old| old.as_array().and_then(|parts| parts.first().map(|p| doc.resolve(p))))
.filter(|first| first.as_string().is_some_and(|s| (1..=MAX_STRING).contains(&s.bytes.len())));
let kept = match (permanent, id.first_mut()) {
(Some(permanent), Some(slot)) => {
*slot = (*permanent).clone();
true
}
_ => false,
};
doc.trailer_mut().set(b"ID".to_vec(), Object::Array(id));
Some(if kept { "Completed the file identifier (ID)" } else { "Added a file identifier (ID)" }.into())
}
+391
View File
@@ -0,0 +1,391 @@
//! PDF/A-2 6.2.10: a graphics state's blend mode is one ISO 32000-1 defines, and a page that
//! contains transparency, in a file without a PDF/A output intent, names its blending colour
//! space: its `/Group` dictionary has a `/CS` entry, which as any colour space in such a file is
//! not a device space without a default (6.2.4.3).
//!
//! A page contains transparency when something its resources reach can paint transparently (ISO
//! 32000-1 11.6.4.4 to 11.6.6): a graphics state with a constant alpha (`/CA`, `/ca`) below 1,
//! a blend mode other than `Normal` or `Compatible`, or a soft mask; an image with a soft mask
//! (`/SMask`, or `/SMaskInData` above 0); or a transparency group XObject. The resources looked
//! through are the page's own, those of the form XObjects, tiling patterns and Type 3 fonts they
//! hold, and those of the appearance streams of the page's annotations. Whether the content uses
//! a resource is not asked: a resource a page holds is one it may paint with. Blend modes are read
//! from the same graphics states, so private data (`/PieceInfo`) is never taken for one.
use std::collections::HashMap;
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
/// How many dictionaries one page's search reads, across its resources, XObjects, patterns, Type
/// 3 fonts and appearance streams. An object [`Shared`] holds counts as one read.
const MAX_VISITS: usize = 4096;
/// How deep resources nest (a form XObject in a form XObject, ...).
const MAX_DEPTH: usize = 32;
/// The resource categories that hold what a page paints with, read in this order.
const CATEGORIES: [&[u8]; 4] = [b"ExtGState", b"XObject", b"Pattern", b"Font"];
/// How many unknown blend mode names one search gathers: enough to name them in the report,
/// without a `/BM` array of a million names comparing each with all the others.
const MAX_BLEND_NAMES: usize = 32;
/// How many objects [`Shared`] keeps what was found under.
const MAX_SHARED: usize = 1 << 16;
/// What the graphics a page's resources reach paint with.
#[derive(Clone, Debug, Default)]
pub(crate) struct Painting {
/// Something paints transparently.
pub(crate) transparency: bool,
/// The blend modes graphics states name that ISO 32000-1 doesn't define.
pub(crate) unknown_blend_modes: Vec<String>,
}
impl Painting {
fn add(&mut self, other: &Painting) {
self.transparency |= other.transparency;
for name in &other.unknown_blend_modes {
self.add_blend_mode(name);
}
}
/// Whether no more unknown blend mode names are gathered.
fn blend_modes_full(&self) -> bool {
self.unknown_blend_modes.len() >= MAX_BLEND_NAMES
}
fn add_blend_mode(&mut self, name: &str) {
if !self.blend_modes_full() && !self.unknown_blend_modes.iter().any(|n| n == name) {
self.unknown_blend_modes.push(name.to_owned());
}
}
}
/// What was found under the indirect objects (resources, form XObjects, patterns, Type 3 fonts,
/// graphics states) that pages share, so the next page that names one takes it from here instead
/// of walking it again. Only an object read to the end goes in: none whose reading stopped below
/// it at the budget, at the depth limit, or at an object already read on that page (a cycle).
#[derive(Default)]
pub(crate) struct Shared(HashMap<ObjRef, Painting>);
/// An object read in place, or the object a reference names as the document holds it: either way
/// nothing is copied.
enum Held<'o> {
Direct(&'o Object),
Resolved(Arc<Object>),
}
impl std::ops::Deref for Held<'_> {
type Target = Object;
fn deref(&self) -> &Object {
match self {
Held::Direct(o) => o,
Held::Resolved(o) => o,
}
}
}
/// `o`, or the object it refers to, without copying either ([`Document::resolve`] copies a direct
/// object).
fn held<'o>(doc: &Document, o: &'o Object) -> Held<'o> {
match o {
Object::Ref(_) => Held::Resolved(doc.resolve(o)),
o => Held::Direct(o),
}
}
/// What the page `page` paints with: its resources, and the appearance streams of its
/// annotations. `shared` carries what earlier pages found under the objects they share.
pub(crate) fn page_painting(doc: &Document, page: &Dict, shared: &mut Shared) -> Painting {
walk_page(doc, page, shared).found
}
/// How many reads the search of `page` took, each object taken from `shared` counting as one.
#[cfg(test)]
pub(crate) fn page_reads(doc: &Document, page: &Dict, shared: &mut Shared) -> usize {
walk_page(doc, page, shared).visits
}
fn walk_page<'a>(doc: &'a Document, page: &Dict, shared: &'a mut Shared) -> Walk<'a> {
let mut walk = Walk { doc, shared, seen: HashMap::new(), visits: 0, cuts: 0, found: Painting::default() };
if let Some(r) = page.get(b"Resources") {
walk.visit(r, 0, Walk::resources);
}
let annots = page.get(b"Annots").map(|a| held(doc, a));
for a in annots.as_deref().and_then(Object::as_array).into_iter().flatten() {
let a = held(doc, a);
let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| held(doc, ap)) else { continue };
let Some(ap) = ap.as_dict() else { continue };
// Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state.
for (_, v) in ap.iter() {
match &*held(doc, v) {
Object::Stream(_) => walk.visit(v, 0, Walk::xobject),
Object::Dict(states) => {
for (_, s) in states.iter() {
walk.visit(s, 0, Walk::xobject);
}
}
_ => {}
}
}
}
walk
}
/// Whether `page` names a blending colour space PDF/A accepts without an output intent: its
/// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4:
/// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes
/// gray, RGB or CMYK; not Lab, Indexed, Pattern, Separation or DeviceN), and not a device colour
/// space unless the page's resources give that device space a default (`DefaultGray`,
/// `DefaultRGB`, `DefaultCMYK`) that is itself such a space with as many components (ISO
/// 32000-1 8.6.5.6), as PDF/A-2 6.2.4.3 asks of any device colour.
pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool {
let group = page.get(b"Group").map(|g| doc.resolve(g));
let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false };
let (default, components): (&[u8], i64) = match &*cs {
Object::Name(n) if n == b"DeviceGray" => (b"DefaultGray", 1),
Object::Name(n) if n == b"DeviceRGB" => (b"DefaultRGB", 3),
Object::Name(n) if n == b"DeviceCMYK" => (b"DefaultCMYK", 4),
Object::Array(a) => return independent_blending_space(doc, a).is_some(),
_ => return false,
};
let resources = page.get(b"Resources").map(|r| doc.resolve(r));
let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c));
let space = spaces.as_deref().and_then(Object::as_dict).and_then(|s| s.get(default)).map(|d| doc.resolve(d));
space.as_deref().and_then(Object::as_array).and_then(|a| independent_blending_space(doc, a)) == Some(components)
}
/// The number of components of the colour space array `a` when it is a device-independent
/// blending colour space: `CalGray` (1), `CalRGB` (3), or `ICCBased` with 1, 3 or 4 components
/// whose profile header, where it can be read (within the output intent's profile cap), says
/// gray, RGB or CMYK to match.
fn independent_blending_space(doc: &Document, a: &[Object]) -> Option<i64> {
let family = a.first().map(|f| doc.resolve(f));
// A CalGray or CalRGB space needs its dictionary, with the required `/WhitePoint` [X Y Z]: X
// and Z positive, Y 1 (ISO 32000-1 Tables 63 and 64).
let calibrated = || {
let params = a.get(1).map(|p| doc.resolve(p));
let white = params.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"WhitePoint")).map(|w| doc.resolve(w));
let values: Option<Vec<f64>> = white.as_deref().and_then(Object::as_array).and_then(|w| w.iter().map(|v| doc.resolve(v).as_f64()).collect());
matches!(values.as_deref(), Some(&[x, y, z]) if x > 0.0 && y == 1.0 && z > 0.0)
};
match family.as_deref().and_then(Object::as_name)? {
b"CalGray" => calibrated().then_some(1),
b"CalRGB" => calibrated().then_some(3),
b"ICCBased" => {
let profile = a.get(1).map(|p| doc.resolve(p));
let Some(Object::Stream(s)) = profile.as_deref() else { return None };
let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int())?;
// ICC.1 header: the data colour space is bytes 16–19.
let header: Option<i64> = match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) {
Some(space) => match space.as_slice() {
b"GRAY" => Some(1),
b"RGB " => Some(3),
b"CMYK" => Some(4),
_ => return None,
},
None => None,
};
(matches!(components, 1 | 3 | 4) && header.is_none_or(|h| h == components)).then_some(components)
}
_ => None,
}
}
struct Walk<'a> {
doc: &'a Document,
shared: &'a mut Shared,
/// The indirect objects already read on this page, each with the shallowest depth it was read
/// at: an object is read again only from a shallower place, so a cycle ends and a deep first
/// visit doesn't hide what a shallower one would read.
seen: HashMap<ObjRef, usize>,
visits: usize,
/// How many times reading stopped short (the budget, the depth limit, an object already read
/// on this page). An object with a stop below it doesn't go in [`Shared`]: what was found
/// under it may be incomplete.
cuts: usize,
found: Painting,
}
impl Walk<'_> {
/// Read `o`, reached `depth` levels down, with `read`: from [`Shared`] when an earlier page
/// read it to the end, else when it is within the budget and the depth, and not an indirect
/// object read before on this page from as shallow a place.
fn visit(&mut self, o: &Object, depth: usize, read: fn(&mut Self, &Object, usize)) {
if self.visits >= MAX_VISITS || depth > MAX_DEPTH {
self.cuts = self.cuts.saturating_add(1);
return;
}
let Object::Ref(r) = o else {
self.visits += 1;
read(self, o, depth);
return;
};
if let Some(found) = self.shared.0.get(r) {
self.visits += 1;
self.found.add(found);
return;
}
if self.seen.get(r).is_some_and(|&d| d <= depth) {
self.cuts = self.cuts.saturating_add(1);
return;
}
self.seen.insert(*r, depth);
self.visits += 1;
// Gather what this object holds on its own, to keep for the next page, then add it to what
// the page holds.
let outer = std::mem::take(&mut self.found);
let cuts = self.cuts;
read(self, o, depth);
let below = std::mem::replace(&mut self.found, outer);
self.found.add(&below);
if self.cuts == cuts && self.shared.0.len() < MAX_SHARED {
self.shared.0.insert(*r, below);
}
}
fn resources(&mut self, r: &Object, depth: usize) {
let doc = self.doc;
let res = held(doc, r);
let Some(res) = res.as_dict() else { return };
for category in CATEGORIES {
let entries = res.get(category).map(|d| held(doc, d));
let Some(entries) = entries.as_deref().and_then(Object::as_dict) else { continue };
for (_, v) in entries.iter() {
// Once the budget is spent nothing more is read, so nothing more is gathered either.
if self.visits >= MAX_VISITS {
self.cuts = self.cuts.saturating_add(1);
return;
}
match category {
b"ExtGState" => self.visit(v, depth, Self::ext_gstate),
b"XObject" => self.visit(v, depth, Self::xobject),
b"Pattern" => self.visit(v, depth, Self::pattern),
_ => self.visit(v, depth, Self::type3),
}
}
}
}
/// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend
/// modes it names that ISO 32000-1 doesn't define.
fn ext_gstate(&mut self, g: &Object, _depth: usize) {
let doc = self.doc;
let g = held(doc, g);
let Some(g) = g.as_dict() else { return };
let below_one = |key: &[u8]| g.get(key).and_then(|v| held(doc, v).as_f64()).is_some_and(|a| a < 1.0);
let bm = g.get(b"BM").map(|b| held(doc, b));
let blend = bm.as_deref().is_some_and(|b| blends(doc, b));
// A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58).
let mask = g.get(b"SMask").map(|m| held(doc, m)).is_some_and(|m| matches!(&*m, Object::Dict(_)));
self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask;
if let Some(bm) = bm.as_deref() {
unknown_blend_modes(doc, bm, &mut self.found);
}
}
/// An image with a soft mask, or a form XObject that is a transparency group or holds
/// transparency in its resources.
fn xobject(&mut self, x: &Object, depth: usize) {
let doc = self.doc;
let x = held(doc, x);
let Object::Stream(s) = &*x else { return };
let d = &s.dict;
if d.name(b"Subtype") == Some(b"Image") {
let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_)));
let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0);
self.found.transparency |= soft_mask || in_data;
return;
}
let group = d.get(b"Group").map(|g| held(doc, g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency")));
self.found.transparency |= group;
if let Some(r) = d.get(b"Resources") {
self.visit(r, depth + 1, Self::resources);
}
}
/// A tiling pattern's resources, or a shading pattern's graphics state.
fn pattern(&mut self, p: &Object, depth: usize) {
let p = held(self.doc, p);
let Some(d) = (match &*p {
Object::Stream(s) => Some(&s.dict),
Object::Dict(d) => Some(d),
_ => None,
}) else {
return;
};
if let Some(g) = d.get(b"ExtGState") {
self.visit(g, depth + 1, Self::ext_gstate);
}
if let Some(r) = d.get(b"Resources") {
self.visit(r, depth + 1, Self::resources);
}
}
/// A Type 3 font's resources, which its glyph procedures paint with.
fn type3(&mut self, f: &Object, depth: usize) {
let f = held(self.doc, f);
let Some(d) = f.as_dict() else { return };
if d.name(b"Subtype") != Some(b"Type3") {
return;
}
if let Some(r) = d.get(b"Resources") {
self.visit(r, depth + 1, Self::resources);
}
}
}
/// The blend modes of ISO 32000-1 11.3.5 (Tables 136 and 137), and `Compatible` (Table 58).
const BLEND_MODES: &[&[u8]] = &[
b"Normal",
b"Compatible",
b"Multiply",
b"Screen",
b"Overlay",
b"Darken",
b"Lighten",
b"ColorDodge",
b"ColorBurn",
b"HardLight",
b"SoftLight",
b"Difference",
b"Exclusion",
b"Hue",
b"Saturation",
b"Color",
b"Luminosity",
];
/// Add to `found` the names in a `/BM` value (a name, or an array of them) that are not blend
/// modes ISO 32000-1 defines, up to [`MAX_BLEND_NAMES`] of them.
fn unknown_blend_modes(doc: &Document, bm: &Object, found: &mut Painting) {
let one = |found: &mut Painting, n: &[u8]| {
if !BLEND_MODES.contains(&n) {
found.add_blend_mode(&String::from_utf8_lossy(n));
}
};
match bm {
Object::Name(n) => one(found, n),
Object::Array(a) => {
for m in a {
if found.blend_modes_full() {
return;
}
if let Some(n) = held(doc, m).as_name() {
one(found, n);
}
}
}
_ => {}
}
}
/// Whether a `/BM` value blends: a name other than `Normal` or `Compatible`, or an array whose
/// first name is not one of those.
fn blends(doc: &Document, b: &Object) -> bool {
let normal = |n: &[u8]| n == b"Normal" || n == b"Compatible";
match b {
Object::Name(n) => !normal(n),
Object::Array(a) => a.iter().find_map(|m| doc.resolve(m).as_name().map(|n| !normal(n))).unwrap_or(false),
_ => false,
}
}
+251
View File
@@ -0,0 +1,251 @@
//! PDF/A-2 6.6.2.3.3, the extension schema container schema: a packet describes each schema it
//! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description
//! is complete.
//!
//! The schemas, their entries and their prefixes are those of PDF Association TechNote 0009, "XMP
//! Extension Schemas in PDF/A-1" (2008), section 4, for PDF/A-2 6.6.2.3.3:
//! - a schema (`pdfaSchema`, 4.3) has `schema`, `namespaceURI` and `prefix`; its `property` and
//! `valueType` lists are optional. TechNote 0009, written for PDF/A-1, calls `schema` an
//! optional description; PDF/A-2 requires it, as its conformance tests show;
//! - a property (`pdfaProperty`, 4.4) has `name`, `valueType`, `category` and `description`;
//! - a value type (`pdfaType`, 4.5) has `type`, `namespaceURI`, `prefix`, `description` and
//! `field`. TechNote 0009 calls `field` optional; PDF/A-2 requires it, as its conformance tests
//! show;
//! - a field (`pdfaField`, 4.6) has `name`, `valueType` and `description`;
//! - each of the five namespaces is written with its own prefix (`pdfaExtension`, `pdfaSchema`,
//! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace
//! prefixes are not only preferred, but required" (4.1).
//!
//! An entry counts as present written as an element or as an attribute of the description. An
//! entry that names something is empty when it holds no value: a namespace URI or prefix, a
//! property, value type or field name, a value type reference, a property category. TechNote 0009
//! asks for names that are valid XML names (4.4, 4.6) and a category that is internal or external
//! (4.4), and an empty prefix or namespace binds nothing. The human-readable descriptions
//! (`pdfaSchema:schema` and the `description` entries) are Text and may be empty.
use pdfcraft_cos::ObjRef;
use roxmltree::{Document, Node};
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
const EXTENSION: &str = "http://www.aiim.org/pdfa/ns/extension/";
const SCHEMA: &str = "http://www.aiim.org/pdfa/ns/schema#";
const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#";
const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#";
const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#";
/// Each container namespace with its required prefix (TechNote 0009, 4.1).
const PREFIXES: [(&str, &str); 5] =
[(EXTENSION, "pdfaExtension"), (SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")];
/// How many findings a document's packets are read for. A document past this many has plainly
/// failed the clause, and listing every one of a hostile packet's descriptions helps nobody.
const MAX_FINDINGS: usize = 1000;
/// How much of a name the packet gives (a namespace URI, a property) a finding repeats.
const MAX_SHOWN_CHARS: usize = 200;
/// What the extension schema descriptions of a document break of 6.6.2.3.3, in the order the
/// metadata streams and their descriptions come.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Findings {
/// Entries descriptions lack.
pub(crate) missing: Vec<Missing>,
/// Description namespaces written with another prefix, each once per stream and prefix.
pub(crate) prefixes: Vec<Prefix>,
/// `pdfaExtension:schemas` containers that aren't one `rdf:Bag` (TechNote 0009, 4.2: "bag
/// Schema").
pub(crate) not_bag: Vec<NotBag>,
}
/// A description without an entry it needs, or with the entry empty.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Missing {
/// The metadata stream.
pub(crate) object: ObjRef,
/// The schema the description is in: its `pdfaSchema:namespaceURI`, or `#n` for the n-th
/// schema of its container when it has none.
pub(crate) schema: String,
/// The property, value type or field description, when it is one of those and not the schema
/// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or
/// `#n` for the n-th of its list when it has no name.
pub(crate) item: Option<String>,
/// The entry it lacks or holds empty, as `prefix:name`.
pub(crate) entry: String,
/// Whether the entry is there but empty, rather than absent.
pub(crate) empty: bool,
}
/// A description namespace written with another prefix than its own.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Prefix {
pub(crate) object: ObjRef,
pub(crate) namespace: &'static str,
/// The prefix it needs.
pub(crate) own: &'static str,
/// The prefix it is written with, empty when it is written as the default namespace.
pub(crate) written: String,
}
/// A `pdfaExtension:schemas` container that isn't one `rdf:Bag`.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct NotBag {
pub(crate) object: ObjRef,
/// The elements it holds instead, by their names as the packet writes them, comma-separated;
/// empty when it holds none.
pub(crate) found: String,
}
/// Where a description is: its stream, its schema and, below the schema, its own name.
struct Place {
object: ObjRef,
schema: String,
item: Option<String>,
}
impl Findings {
/// Adds what the packet `doc`, from the metadata stream `object`, breaks.
pub(crate) fn add(&mut self, object: ObjRef, doc: &Document<'_>) {
let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas"));
for container in containers {
self.prefixes_in(object, doc, &container);
let children: Vec<Node<'_, '_>> = container.children().filter(Node::is_element).collect();
if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) && self.room() {
let found = children.iter().take(MAX_SHOWN_CHARS).map(|c| written_name(doc, c)).collect::<Vec<_>>().join(", ");
self.not_bag.push(NotBag { object, found: shown(&found) });
}
for (n, schema) in items(&container).iter().enumerate() {
let id = value(schema, SCHEMA, "namespaceURI").unwrap_or_else(|| position(n));
let at = |item: Option<String>| Place { object, schema: id.clone(), item };
self.need(&at(None), schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]);
let properties = entries(schema, SCHEMA, "property").flat_map(|e| items(&e));
for (n, property) in properties.enumerate() {
let name = value(&property, PROPERTY, "name").unwrap_or_else(|| position(n));
self.need(&at(Some(name)), &property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]);
}
let value_types = entries(schema, SCHEMA, "valueType").flat_map(|e| items(&e));
for (n, value_type) in value_types.enumerate() {
let name = value(&value_type, TYPE, "type").unwrap_or_else(|| position(n));
self.need(&at(Some(name.clone())), &value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]);
for (n, field) in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)).enumerate() {
let field_name = value(&field, FIELD, "name").unwrap_or_else(|| position(n));
self.need(&at(Some(format!("{name}/{field_name}"))), &field, FIELD, "pdfaField", &["name", "valueType", "description"]);
}
}
}
}
}
/// Whether another finding may be recorded (see [`MAX_FINDINGS`]).
fn room(&self) -> bool {
self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS
}
/// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks, or holds
/// empty when they are entries that name something (see [`NAMING`]).
fn need(&mut self, at: &Place, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) {
for name in names {
let element = entry(d, ns, name);
let attribute = d.attribute((ns, *name));
let empty = match (element, attribute) {
(None, None) => false,
(Some(e), _) => NAMING.contains(name) && is_empty(&e),
(None, Some(a)) => NAMING.contains(name) && a.trim().is_empty(),
};
if (empty || (element.is_none() && attribute.is_none())) && self.room() {
let Place { object, schema, item } = at;
let entry = format!("{prefix}:{name}");
self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry, empty });
}
}
}
/// Notes container namespaces written in `container` (itself included) with another prefix
/// than their own.
fn prefixes_in(&mut self, object: ObjRef, doc: &Document<'_>, container: &Node<'_, '_>) {
let text = doc.input_text();
for n in container.descendants().filter(Node::is_element) {
let element = (n.tag_name().namespace(), text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default());
let attributes = n.attributes().map(|a| (a.namespace(), text.get(a.range_qname()).unwrap_or_default()));
for (ns, qname) in std::iter::once(element).chain(attributes) {
let Some(&(namespace, own)) = PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns) else { continue };
let written = shown(written_prefix(qname));
if written != own
&& self.room()
&& !self.prefixes.iter().any(|p| p.object == object && p.namespace == namespace && p.written == written)
{
self.prefixes.push(Prefix { object, namespace, own, written });
}
}
}
}
}
/// The entries that name something, which an empty value leaves without a name: namespace URIs
/// and prefixes, property, value type and field names, value type references and the property
/// category.
const NAMING: [&str; 6] = ["namespaceURI", "prefix", "name", "type", "valueType", "category"];
/// Whether the element entry `e` holds no value: no element, no text but white space, and no
/// `rdf:resource` or `rdf:value` attribute with a value.
fn is_empty(e: &Node<'_, '_>) -> bool {
let blank = |v: Option<&str>| v.is_none_or(|v| v.trim().is_empty());
!e.children().any(|c| c.is_element() || (c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty())))
&& blank(e.attribute((RDF, "resource")))
&& blank(e.attribute((RDF, "value")))
}
/// `#n` for the 0-based position `n`, counted from 1.
fn position(n: usize) -> String {
format!("#{}", n.saturating_add(1))
}
/// At most [`MAX_SHOWN_CHARS`] of `s`, cut at a char boundary, with `…` when cut.
fn shown(s: &str) -> String {
let mut out: String = s.chars().take(MAX_SHOWN_CHARS).collect();
if out.len() < s.len() {
out.push('…');
}
out
}
/// The text of the entry `name` of `ns` in the description `d`, written as an element or an
/// attribute, trimmed and [`shown`]; `None` when it is absent or blank.
fn value(d: &Node<'_, '_>, ns: &str, name: &str) -> Option<String> {
let text = entry(d, ns, name).and_then(|e| e.text()).or_else(|| d.attribute((ns, name)))?.trim();
(!text.is_empty()).then(|| shown(text))
}
/// The name of the element `n` as the packet writes it, prefix included.
fn written_name<'i>(doc: &Document<'i>, n: &Node<'_, '_>) -> &'i str {
qualified_name(doc.input_text().get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default())
}
/// The qualified name the text of a start tag (after its `<`) or of an attribute begins with.
fn qualified_name(text: &str) -> &str {
text.split(|c: char| c.is_whitespace() || c == '>' || c == '/' || c == '=').next().unwrap_or_default()
}
/// The prefix of the qualified name `text` begins with, empty when it has none.
fn written_prefix(text: &str) -> &str {
qualified_name(text).split_once(':').map_or("", |(p, _)| p)
}
fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool {
n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name
}
/// Every element entry `name` of `ns` in the description `d`: a list written twice is read twice.
fn entries<'a, 'i>(d: &Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator<Item = Node<'a, 'i>> {
d.children().filter(move |c| is(c, ns, name))
}
/// The element entry `name` of `ns` in the description `d`.
fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option<Node<'a, 'i>> {
d.children().find(|c| is(c, ns, name))
}
/// The descriptions in the array `e` holds: each item's `rdf:parseType="Resource"` node or
/// `rdf:Description`.
fn items<'a, 'i>(e: &Node<'a, 'i>) -> Vec<Node<'a, 'i>> {
let Some(array) = e.children().find(|c| is(c, RDF, "Bag") || is(c, RDF, "Seq") || is(c, RDF, "Alt")) else { return Vec::new() };
array.children().filter(|i| is(i, RDF, "li")).map(|li| li.children().find(|c| is(c, RDF, "Description")).unwrap_or(li)).collect()
}
+158 -5
View File
@@ -4,7 +4,10 @@
//! The types come from ISO 16684-1:2011 (XMP Part 1) clause 8 for the Dublin Core, XMP Basic and
//! XMP Rights Management namespaces, and from XMP Specification Part 2 (Additional Properties) for
//! the Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and
//! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it
//! Camera Raw namespaces, with the structures they use. The TIFF, EXIF and Additional EXIF (`aux`)
//! namespaces, which the current Part 2 leaves to CIPA DC-010, come from the September 2005 XMP
//! Specification that PDF/A-2 cites: "EXIF Schemas" (pages 58–70) and "EXIF Schema Value Types"
//! (pages 83–87). A value is read as RDF/XML serializes it
//! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or
//! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a
//! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the
@@ -12,7 +15,8 @@
//!
//! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or
//! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms
//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it.
//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it and
//! the 2005 edition defines it (page 84); GPSCoordinate `DDD,MM,SSk` or `DDD,MM.mmk` (page 85).
//! The other simple types are text. A field a structure doesn't define, and a property a
//! namespace doesn't define, are left to the clauses about them.
@@ -43,6 +47,8 @@ enum Ty {
Boolean,
Date,
Rational,
/// `DDD,MM,SSk` or `DDD,MM.mmk`, `k` one of N, S, E, W (2005 edition, page 85).
GpsCoordinate,
/// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4).
LangAlt,
Seq(&'static Ty),
@@ -203,6 +209,19 @@ const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)];
/// Part 2 Table 32.
const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)];
const EXIF: &str = "http://ns.adobe.com/exif/1.0/";
/// Flash (2005 edition, page 85).
const FLASH: &[Field] =
&[(EXIF, "Fired", Boolean), (EXIF, "Return", Integer), (EXIF, "Mode", Integer), (EXIF, "Function", Boolean), (EXIF, "RedEyeMode", Boolean)];
/// OECF/SFR (2005 edition, page 86).
const OECF_SFR: &[Field] =
&[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Names", Ty::Seq(&Text)), (EXIF, "Values", Ty::Seq(&Rational))];
/// CFAPattern (2005 edition, page 87).
const CFA_PATTERN: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Values", Ty::Seq(&Integer))];
/// DeviceSettings (2005 edition, page 87).
const DEVICE_SETTINGS: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Settings", Ty::Seq(&Text))];
const SCHEMAS: &[Schema] = &[
// ISO 16684-1 Table 4.
Schema {
@@ -452,6 +471,126 @@ const SCHEMAS: &[Schema] = &[
("WhiteBalance", Text),
],
},
// 2005 edition, "EXIF Schema for TIFF Properties", pages 58–60. A Closed Choice of Integer is an
// Integer.
Schema {
uri: "http://ns.adobe.com/tiff/1.0/",
prefix: "tiff",
properties: &[
("ImageWidth", Integer),
("ImageLength", Integer),
("BitsPerSample", Ty::Seq(&Integer)),
("Compression", Integer),
("PhotometricInterpretation", Integer),
("Orientation", Integer),
("SamplesPerPixel", Integer),
("PlanarConfiguration", Integer),
("YCbCrSubSampling", Ty::Seq(&Integer)),
("YCbCrPositioning", Integer),
("XResolution", Rational),
("YResolution", Rational),
("ResolutionUnit", Integer),
("TransferFunction", Ty::Seq(&Integer)),
("WhitePoint", Ty::Seq(&Rational)),
("PrimaryChromaticities", Ty::Seq(&Rational)),
("YCbCrCoefficients", Ty::Seq(&Rational)),
("ReferenceBlackWhite", Ty::Seq(&Rational)),
("DateTime", Date),
("ImageDescription", LangAlt),
("Make", Text),
("Model", Text),
("Software", Text),
("Artist", Text),
("Copyright", LangAlt),
],
},
// 2005 edition, "EXIF Schema for EXIF-specific Properties", pages 60–69.
Schema {
uri: EXIF,
prefix: "exif",
properties: &[
("ExifVersion", Text),
("FlashpixVersion", Text),
("ColorSpace", Integer),
("ComponentsConfiguration", Ty::Seq(&Integer)),
("CompressedBitsPerPixel", Rational),
("PixelXDimension", Integer),
("PixelYDimension", Integer),
("UserComment", LangAlt),
("RelatedSoundFile", Text),
("DateTimeOriginal", Date),
("DateTimeDigitized", Date),
("ExposureTime", Rational),
("FNumber", Rational),
("ExposureProgram", Integer),
("SpectralSensitivity", Text),
("ISOSpeedRatings", Ty::Seq(&Integer)),
("OECF", Ty::Struct("OECF/SFR", OECF_SFR)),
("ShutterSpeedValue", Rational),
("ApertureValue", Rational),
("BrightnessValue", Rational),
("ExposureBiasValue", Rational),
("MaxApertureValue", Rational),
("SubjectDistance", Rational),
("MeteringMode", Integer),
("LightSource", Integer),
("Flash", Ty::Struct("Flash", FLASH)),
("FocalLength", Rational),
("SubjectArea", Ty::Seq(&Integer)),
("FlashEnergy", Rational),
("SpatialFrequencyResponse", Ty::Struct("OECF/SFR", OECF_SFR)),
("FocalPlaneXResolution", Rational),
("FocalPlaneYResolution", Rational),
("FocalPlaneResolutionUnit", Integer),
("SubjectLocation", Ty::Seq(&Integer)),
("ExposureIndex", Rational),
("SensingMethod", Integer),
("FileSource", Integer),
("SceneType", Integer),
("CFAPattern", Ty::Struct("CFAPattern", CFA_PATTERN)),
("CustomRendered", Integer),
("ExposureMode", Integer),
("WhiteBalance", Integer),
("DigitalZoomRatio", Rational),
("FocalLengthIn35mmFilm", Integer),
("SceneCaptureType", Integer),
("GainControl", Integer),
("Contrast", Integer),
("Saturation", Integer),
("Sharpness", Integer),
("DeviceSettingDescription", Ty::Struct("DeviceSettings", DEVICE_SETTINGS)),
("SubjectDistanceRange", Integer),
("ImageUniqueID", Text),
("GPSVersionID", Text),
("GPSLatitude", Ty::GpsCoordinate),
("GPSLongitude", Ty::GpsCoordinate),
("GPSAltitudeRef", Integer),
("GPSAltitude", Rational),
("GPSTimeStamp", Date),
("GPSSatellites", Text),
("GPSStatus", Text),
("GPSMeasureMode", Text),
("GPSDOP", Rational),
("GPSSpeedRef", Text),
("GPSSpeed", Rational),
("GPSTrackRef", Text),
("GPSTrack", Rational),
("GPSImgDirectionRef", Text),
("GPSImgDirection", Rational),
("GPSMapDatum", Text),
("GPSDestLatitude", Ty::GpsCoordinate),
("GPSDestLongitude", Ty::GpsCoordinate),
("GPSDestBearingRef", Text),
("GPSDestBearing", Rational),
("GPSDestDistanceRef", Text),
("GPSDestDistance", Rational),
("GPSProcessingMethod", Text),
("GPSAreaInformation", Text),
("GPSDifferential", Integer),
],
},
// 2005 edition, "EXIF Schema for Additional EXIF Properties", page 69.
Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] },
];
/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that
@@ -535,8 +674,8 @@ fn written(qname: &str, local: &str) -> String {
format!("{out}:{local}")
}
/// `ty` as the XMP Specification writes value types: `Integer`, `Lang Alt`, `seq Rational`, a
/// structure by its type's name, such as `Dimensions`.
/// `ty` as the XMP Specification writes value types: `Integer`, `GPSCoordinate`, `Lang Alt`,
/// `seq Rational`, a structure by its type's name, such as `Flash`.
fn named(ty: Ty) -> String {
let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item));
match ty {
@@ -547,6 +686,7 @@ fn named(ty: Ty) -> String {
Boolean => "Boolean".into(),
Date => "Date".into(),
Rational => "Rational".into(),
Ty::GpsCoordinate => "GPSCoordinate".into(),
LangAlt => "Lang Alt".into(),
Ty::Seq(item) => array("seq", item),
Ty::Bag(item) => array("bag", item),
@@ -608,7 +748,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool {
return blank(e) && blank(&n) && simple(ty, v) && predefined_ok(&n, deeper);
}
match ty {
Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => {
Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational | Ty::GpsCoordinate => {
// `rdf:resource` makes the value a URI, which only a text type takes.
// The element is then empty.
if e.attribute((RDF, "resource")).is_some() {
@@ -716,12 +856,25 @@ fn simple(ty: Ty, v: &str) -> bool {
Ty::Boolean => matches!(v, "True" | "False"),
Ty::Date => date(v),
Ty::Rational => v.split_once('/').is_some_and(|(n, d)| integer(n) && integer(d)),
Ty::GpsCoordinate => gps_coordinate(v),
Ty::Text | Ty::Guid => true,
Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)),
_ => false,
}
}
/// `DDD,MM,SSk` or `DDD,MM.mmk`: degrees, minutes and seconds or minutes with any number of
/// fractional digits, then N, S, E or W (2005 edition, page 85).
fn gps_coordinate(v: &str) -> bool {
let Some(v) = v.strip_suffix(['N', 'S', 'E', 'W']) else { return false };
let Some((degrees, rest)) = v.split_once(',') else { return false };
let minutes_ok = match rest.split_once(',') {
Some((minutes, seconds)) => digits(minutes) && digits(seconds),
None => rest.split_once('.').is_some_and(|(m, f)| digits(m) && digits(f)),
};
digits(degrees) && minutes_ok
}
fn digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
+3 -1
View File
@@ -4,7 +4,9 @@ use pdfcraft_preflight::{Level, convert};
use std::sync::Arc;
fn image_file(damaged: bool) -> Arc<Vec<u8>> {
let mut bytes = b"%PDF-1.7\n".to_vec();
// A PDF/A-2 header (6.1.2), so that Save as PDF/A has no header to rewrite and only the
// repaired references decide whether the next save is full.
let mut bytes = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
let bodies = [
"<< /Type /Catalog /Pages 2 0 R /HeaderImage 5 1 R /RowImage 5 0 R /StaleImage 5 2 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+6 -2
View File
@@ -22,7 +22,8 @@ use pdfcraft_preflight::{Level, convert, declared, icc, verify};
/// A document from hand-written objects and extra trailer entries (copied from the crate's unit
/// tests).
fn build(objs: &[&str], trailer_extra: &str) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
// ISO 19005-2 6.1.2: the header line is followed by a comment of four bytes above 127.
let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
@@ -33,7 +34,10 @@ fn build(objs: &[&str], trailer_extra: &str) -> Document {
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
// ISO 19005-2 6.1.3: the trailer names a file identifier; a test about another clause gets one
// unless it gives its own.
let id = if trailer_extra.contains("/ID") { "" } else { "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]" };
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(std::sync::Arc::new(out)).unwrap()
}
+4 -4
View File
@@ -8017,8 +8017,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/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "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, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs and their structures; the tiff, exif and aux namespaces, whose tables XMP Part 2 defers to CIPA DC-010, are not yet checked), 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::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_extension_naming_entries_are_not_empty", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "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", "crates/preflight/src/tests.rs::separation_and_devicen_spaces_are_checked", "crates/preflight/src/tests.rs::separation_and_devicen_checks_follow_references_depth_and_nchannel", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::extended_graphics_states_are_checked", "crates/preflight/src/tests.rs::graphics_state_scan_reads_each_object_once_whatever_the_generation", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added", "crates/preflight/src/tests.rs::set_state_no_op_and_named_actions_are_checked"]
notes = "PDF/A-2b/3b structural rules: the file header (6.1.2), identification, the trailer file identifier (6.1.3), the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, none of the naming entries empty, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions with the primary components of ISO 32000-1 10.5.5.6, rendering intents), annotations, actions (with SetState, NOP and Named actions other than the four page moves), images, embedded files, the implementation limits of 6.1.13 (integers, reals, strings, names, CIDs and page boundaries, ISO 32000-1 Annex C), Separation and DeviceN spaces (6.2.4.4: one definition per colorant name, spot colorants in /Colorants; a device alternate without a matching output intent is not yet reported), and transparency (6.2.10: blend modes ISO 32000-1 defines, and a blending colour space on pages with transparency when there is no PDF/A output intent). Not yet: content-stream operator checks, a transparency group blending colour space checked against the output intent, PDF/A-1 and -4, a veraPDF comparison."
[[feature]]
id = "optimize.pdfa-convert"
@@ -8029,8 +8029,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/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", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added"]
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), a missing or malformed file identifier. 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"