M11: PDF/A (new preflight crate): verify PDF/A-2b/3b structural rules, Save as PDF/A (XMP identification, generated sRGB output intent, forbidden actions, annotation flags, interpolation, encryption), PDF/A dialog; pdfa_verify, pdfa_convert tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 07:19:54 -04:00
parent 632a21bfcf
commit 4d60af8b1e
22 changed files with 1193 additions and 22 deletions

9
Cargo.lock generated
View File

@ -3868,6 +3868,7 @@ dependencies = [
"printcraft-ocr",
"printcraft-optimize",
"printcraft-organize",
"printcraft-preflight",
"printcraft-print",
"printcraft-redact",
"printcraft-render",
@ -3956,6 +3957,14 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "printcraft-preflight"
version = "0.1.0"
dependencies = [
"printcraft-cos",
"printcraft-model",
]
[[package]]
name = "printcraft-print"
version = "0.1.0"

View File

@ -31,6 +31,7 @@ printcraft-a11y = { path = "crates/a11y", version = "0.1.0" }
printcraft-ocr = { path = "crates/ocr", version = "0.1.0" }
printcraft-js = { path = "crates/js", version = "0.1.0" }
printcraft-compare = { path = "crates/compare", version = "0.1.0" }
printcraft-preflight = { path = "crates/preflight", version = "0.1.0" }
printcraft-edit = { path = "crates/edit", version = "0.1.0" }
printcraft-create = { path = "crates/create", version = "0.1.0" }
printcraft-forms = { path = "crates/forms", version = "0.1.0" }

View File

@ -259,6 +259,35 @@ impl Automation {
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
}
fn pdfa_level(&self, a: &Args) -> Result<printcraft_engine::pdfa::Level> {
let l = a.opt_str("level")?.unwrap_or("2b");
printcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)")))
}
pub(crate) fn pdfa_verify(&self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let doc = self.doc(a)?;
let issues: Vec<Value> = doc
.pdfa_verify(level)
.iter()
.map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable }))
.collect();
let d = doc.standards();
Ok(json!({
"level": level.label(),
"compliant": issues.is_empty(),
"issues": issues,
"declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents },
}))
}
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
self.session.apply(id, printcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
self.pdfa_verify(a)
}
pub(crate) fn action_list(&self) -> Result<Value> {
use printcraft_engine::actions::{Step, builtin};
let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() });

View File

@ -513,6 +513,8 @@ impl Automation {
"form_detect_fields" => self.form_detect_fields(&a)?,
"doc_compare" => self.doc_compare(&a)?,
"action_list" => self.action_list()?,
"pdfa_verify" => self.pdfa_verify(&a)?,
"pdfa_convert" => self.pdfa_convert(&a)?,
"action_run" => self.action_run(&a)?,
"doc_compare_report" => self.doc_compare_report(&a)?,
"doc_compare_mark" => self.doc_compare_mark(&a)?,

View File

@ -759,6 +759,13 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
.ro()
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"], "description": "Default 2b." } }), &["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.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
.ro()
.cmd("actions.wizard")

View File

@ -1470,3 +1470,17 @@ fn actions_through_tools() {
assert_eq!(r["files"][0]["log"][0], "Set document title");
assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err());
}
#[test]
fn pdfa_through_tools() {
let dir = workdir("pdfa");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Archive me" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc }));
assert_eq!(r["compliant"], false);
assert!(r["issues"].as_array().unwrap().iter().any(|i| i["clause"] == "6.6.2.1"), "{r}");
let r = ok(&mut a, "pdfa_convert", json!({ "doc": doc, "level": "3b" }));
assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}");
assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}");
assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err());
}

View File

@ -25,6 +25,7 @@ printcraft-a11y = { workspace = true }
printcraft-ocr = { workspace = true }
printcraft-js = { workspace = true }
printcraft-compare = { workspace = true }
printcraft-preflight = { workspace = true }
printcraft-fonts = { workspace = true }
printcraft-create = { workspace = true }
printcraft-model = { workspace = true }

View File

@ -459,11 +459,11 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
icon: "file-check",
hue: RED,
badge: None,
availability: Planned("M11"),
availability: Ready,
sections: &[ToolSection {
title: "Standards",
items: &[
item("Save as PDF/A", "file-check", "standards.pdfa", Planned("M11")),
item("Save as PDF/A", "file-check", "standards.pdfa", Ready),
item("Save as PDF/X", "file-check", "standards.pdfx", Planned("M11")),
item("Save as PDF/UA", "accessibility", "standards.pdfua", Planned("M11")),
item("Preflight", "file-check", "preflight.run", Planned("M11")),

View File

@ -201,6 +201,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"),
c("form.import_data", "Import form data…", None, None, HasFields, "file-input"),
c("form.export_data", "Export form data…", None, None, HasFields, "file-output"),
c("standards.pdfa", "PDF/A…", None, None, Document, "file-check"),
c("actions.wizard", "Action Wizard…", None, None, Nothing, "list-checks"),
c("actions.distribution", "Prepare for distribution…", None, None, Nothing, "send"),
c("actions.optimize_scans", "Optimize scanned documents…", None, None, Nothing, "scan-text"),

View File

@ -17,6 +17,16 @@ pub fn colour(kind: Kind) -> crate::Rgb {
}
impl crate::Document {
/// Standards ▸ Verify PDF/A: the rules the document breaks for `level`.
pub fn pdfa_verify(&self, level: printcraft_preflight::Level) -> Vec<printcraft_preflight::Issue> {
self.editor.as_ref().map(|e| printcraft_preflight::verify(&e.cos, level)).unwrap_or_default()
}
/// The standards the document declares (Standards panel).
pub fn standards(&self) -> printcraft_preflight::Declared {
self.editor.as_ref().map(|e| printcraft_preflight::declared(&e.cos)).unwrap_or_default()
}
/// Every word of the document in reading order, with page and box.
pub fn words(&self) -> Vec<printcraft_compare::Word> {
let config = printcraft_render::RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() };

View File

@ -78,6 +78,7 @@ pub use printcraft_annot::{
};
pub use printcraft_forms::detect;
pub use printcraft_optimize as optimize;
pub use printcraft_preflight as pdfa;
pub use printcraft_print as print;
pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern};
pub use printcraft_redact::sanitize::{HIDDEN, Hidden};
@ -812,6 +813,11 @@ pub enum Edit {
event: String,
script: Option<String>,
},
/// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent,
/// forbidden actions, annotation flags, …).
ConvertPdfA {
level: printcraft_preflight::Level,
},
/// What a script (button or console) changed in form fields: values, read-only, required
/// and visibility.
ApplyScriptChanges {
@ -1013,6 +1019,7 @@ impl Edit {
Edit::MarkDecorative { .. } => "Mark figure as decorative".into(),
Edit::AddOcrText { .. } => "Recognize text".into(),
Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(),
Edit::ConvertPdfA { level } => format!("Save as {}", level.label()),
Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"),
Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(),
Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(),
@ -1186,6 +1193,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
| Edit::AddOcrText { .. }
| Edit::SetDocumentScript { .. }
| Edit::SetFieldScript { .. }
| Edit::ConvertPdfA { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::EditPageImage { .. }
@ -1388,6 +1396,9 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
None => printcraft_forms::recalculate(doc)?,
};
}
Edit::ConvertPdfA { level } => {
printcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?;
}
Edit::SetDocumentScript { name, script } => printcraft_forms::set_document_script(doc, name, script.as_deref())?,
Edit::AddOcrText { page, words } => {
printcraft_edit::stamp(doc, *page, "OCR", printcraft_ocr::text_layer(words))?;

View File

@ -0,0 +1,15 @@
[package]
name = "printcraft-preflight"
description = "Standards: verify PDF/A (ISO 19005) conformance and convert documents to PDF/A-2b or 3b"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
printcraft-cos = { workspace = true }
printcraft-model = { workspace = true }

128
crates/preflight/src/icc.rs Normal file
View File

@ -0,0 +1,128 @@
//! A small sRGB ICC profile (version 2, display class, matrix/TRC), written from the published
//! sRGB primaries (IEC 61966-2-1) adapted to D50, with the sRGB tone curve sampled at 1024
//! points. Used as the PDF/A output intent's destination profile.
fn s15f16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz(x: f64, y: f64, z: f64) -> Vec<u8> {
let mut t = b"XYZ \0\0\0\0".to_vec();
t.extend(s15f16(x));
t.extend(s15f16(y));
t.extend(s15f16(z));
t
}
fn desc(text: &str) -> Vec<u8> {
let mut t = b"desc\0\0\0\0".to_vec();
let ascii: Vec<u8> = text.bytes().chain(std::iter::once(0)).collect();
t.extend((ascii.len() as u32).to_be_bytes());
t.extend(&ascii);
// No Unicode or ScriptCode descriptions.
t.extend([0u8; 4 + 4]);
t.extend([0u8; 2 + 1 + 67]);
t
}
fn text(text: &str) -> Vec<u8> {
let mut t = b"text\0\0\0\0".to_vec();
t.extend(text.bytes());
t.push(0);
t
}
fn curve() -> Vec<u8> {
const N: usize = 1024;
let mut t = b"curv\0\0\0\0".to_vec();
t.extend((N as u32).to_be_bytes());
for i in 0..N {
let v = i as f64 / (N - 1) as f64;
let lin = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
t.extend(((lin * 65535.0).round() as u16).to_be_bytes());
}
t
}
/// The profile's bytes.
pub fn srgb() -> Vec<u8> {
// sRGB primaries, Bradford-adapted to the D50 profile connection space.
let tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"desc", desc("sRGB (PrintCraft)")),
(b"cprt", text("No copyright, use freely")),
(b"wtpt", xyz(0.9642, 1.0, 0.8249)),
(b"rXYZ", xyz(0.4361, 0.2225, 0.0139)),
(b"gXYZ", xyz(0.3851, 0.7169, 0.0971)),
(b"bXYZ", xyz(0.1431, 0.0606, 0.7141)),
(b"rTRC", curve()),
];
// gTRC and bTRC share rTRC's data.
let count = tags.len() + 2;
let table_len = 4 + count * 12;
let mut offset = 128 + table_len;
let mut table = (count as u32).to_be_bytes().to_vec();
let mut data = Vec::new();
let mut trc = (0, 0);
for (sig, body) in &tags {
let len = body.len();
table.extend(*sig);
table.extend((offset as u32).to_be_bytes());
table.extend((len as u32).to_be_bytes());
if *sig == b"rTRC" {
trc = (offset, len);
}
data.extend(body);
// Tags start on 4-byte boundaries.
let pad = (4 - len % 4) % 4;
data.extend(std::iter::repeat_n(0u8, pad));
offset += len + pad;
}
for sig in [b"gTRC", b"bTRC"] {
table.extend(sig);
table.extend((trc.0 as u32).to_be_bytes());
table.extend((trc.1 as u32).to_be_bytes());
}
let size = 128 + table.len() + data.len();
let mut h = Vec::with_capacity(size);
h.extend((size as u32).to_be_bytes());
h.extend(b"\0\0\0\0"); // preferred CMM
h.extend([2, 0x10, 0, 0]); // version 2.1
h.extend(b"mntr");
h.extend(b"RGB ");
h.extend(b"XYZ ");
h.extend([0u8; 12]); // date
h.extend(b"acsp");
h.extend(b"\0\0\0\0"); // platform
h.extend([0u8; 4]); // flags
h.extend([0u8; 4]); // manufacturer
h.extend([0u8; 4]); // model
h.extend([0u8; 8]); // attributes
h.extend([0, 0, 0, 0]); // perceptual intent
h.extend(s15f16(0.9642));
h.extend(s15f16(1.0));
h.extend(s15f16(0.8249));
h.extend([0u8; 4]); // creator
h.resize(128, 0);
h.extend(table);
h.extend(data);
h
}
#[cfg(test)]
mod tests {
#[test]
fn profile_is_well_formed() {
let p = super::srgb();
assert_eq!(u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize, p.len());
assert_eq!(&p[36..40], b"acsp");
assert_eq!(&p[12..20], b"mntrRGB ");
let n = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
assert_eq!(n, 9);
for i in 0..n {
let e = 132 + i * 12;
let off = u32::from_be_bytes(p[e + 4..e + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(p[e + 8..e + 12].try_into().unwrap()) as usize;
assert!(off.is_multiple_of(4) && off + len <= p.len(), "tag {i}");
}
}
}

553
crates/preflight/src/lib.rs Normal file
View File

@ -0,0 +1,553 @@
//! printcraft-preflight — Standards: PDF/A (L4).
//!
//! [`verify`] checks a document against the parts of ISO 19005-2/-3 (PDF/A-2b, PDF/A-3b) that can
//! be decided from the object graph:
//!
//! - identification: XMP metadata with `pdfaid:part` and `pdfaid:conformance`, consistent with
//! the document information dictionary;
//! - no encryption; no LZW; no external streams;
//! - an output intent (`GTS_PDFA1`) with an ICC profile when device colour is used;
//! - 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
//! 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`.
//!
//! [`convert`] fixes what can be fixed without changing how pages look (XMP, the sRGB output
//! intent, forbidden actions, annotation flags, Interpolate, NeedAppearances, encryption) and
//! returns what remains. Fonts that aren't embedded can't be fixed here.
pub mod icc;
mod xmp;
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
/// The PDF/A part and conformance level.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
A2b,
A3b,
}
impl Level {
pub fn part(self) -> u8 {
match self {
Level::A2b => 2,
Level::A3b => 3,
}
}
pub fn label(self) -> &'static str {
match self {
Level::A2b => "PDF/A-2b",
Level::A3b => "PDF/A-3b",
}
}
pub fn from_id(id: &str) -> Option<Level> {
match id.to_ascii_lowercase().replace(['/', '-', ' '], "").as_str() {
"pdfa2b" | "a2b" | "2b" => Some(Level::A2b),
"pdfa3b" | "a3b" | "3b" => Some(Level::A3b),
_ => None,
}
}
}
/// A rule a document breaks.
#[derive(Clone, Debug, PartialEq)]
pub struct Issue {
/// The ISO 19005-2 clause.
pub clause: &'static str,
pub message: String,
/// 0-based page, when the problem is on a page.
pub page: Option<usize>,
/// Whether [`convert`] can fix it.
pub fixable: bool,
}
/// What a document declares (the Standards panel).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Declared {
/// `pdfaid:part` and `pdfaid:conformance`, e.g. (2, "B").
pub pdfa: Option<(u8, String)>,
pub pdfua: Option<u8>,
/// Output intents' condition identifiers.
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_ANNOTS: &[&[u8]] = &[b"Sound", b"Movie", b"Screen", b"3D", b"RichMedia"];
fn catalog(doc: &Document) -> Dict {
doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default()
}
/// The XMP metadata stream of the catalog, as text.
fn metadata(doc: &Document) -> Option<String> {
let cat = catalog(doc);
let m = doc.resolve(cat.get(b"Metadata")?);
match &*m {
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
/// What the document declares about its standards.
pub fn declared(doc: &Document) -> Declared {
let mut d = Declared::default();
if let Some(x) = metadata(doc) {
let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok());
let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default();
d.pdfa = part.map(|p| (p, conf.to_uppercase()));
d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok());
}
let cat = catalog(doc);
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
for oi in list {
if let Some(od) = doc.resolve(&oi).as_dict() {
let id = od.get(b"OutputConditionIdentifier").and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
d.output_intents.push(id);
}
}
}
d
}
/// Every indirect object, with its reference.
fn objects(doc: &Document) -> Vec<(ObjRef, std::sync::Arc<Object>)> {
doc.object_numbers()
.into_iter()
.map(|n| {
let r = ObjRef::new(n, doc.generation(n));
(r, doc.get(r))
})
.collect()
}
fn dict_of(o: &Object) -> Option<&Dict> {
match o {
Object::Dict(d) => Some(d),
Object::Stream(s) => Some(&s.dict),
_ => None,
}
}
fn has_pdfa_intent(doc: &Document) -> bool {
let cat = catalog(doc);
cat.get(b"OutputIntents")
.map(|o| doc.resolve(o))
.and_then(|o| o.as_array().cloned())
.unwrap_or_default()
.iter()
.any(|oi| doc.resolve(oi).as_dict().is_some_and(|d| d.name(b"S") == Some(b"GTS_PDFA1") && d.get(b"DestOutputProfile").is_some()))
}
/// Whether any content or object uses a device colour space.
fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> (bool, bool) {
let (mut rgb_or_gray, mut cmyk) = (false, false);
for (_, o) in objs {
let Some(d) = dict_of(o) else { continue };
if let Some(cs) = d.get(b"ColorSpace").map(|c| doc.resolve(c)) {
match &*cs {
Object::Name(n) if n == b"DeviceRGB" || n == b"DeviceGray" => rgb_or_gray = true,
Object::Name(n) if n == b"DeviceCMYK" => cmyk = true,
_ => {}
}
}
if let Object::Stream(s) = &**o
&& d.get(b"Type").is_none()
&& d.get(b"Subtype").is_none_or(|t| t.as_name() == Some(b"Form"))
&& let Ok(data) = s.decoded()
{
// Content streams: rg/RG/g/G/k/K operators.
for line in data.split(|b| *b == b'\n' || *b == b' ') {
match line {
b"rg" | b"RG" | b"g" | b"G" => rgb_or_gray = true,
b"k" | b"K" => cmyk = true,
_ => {}
}
}
}
}
(rgb_or_gray, cmyk)
}
/// Check `doc` against `level`.
pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
let mut out = Vec::new();
let mut issue = |clause: &'static str, message: String, page: Option<usize>, fixable: bool| out.push(Issue { clause, message, page, fixable });
let cat = catalog(doc);
// 6.1.3 / 6.6: identification and metadata.
match metadata(doc) {
None => issue("6.6.2.1", "The document has no XMP metadata".into(), None, true),
Some(x) => {
let part = xmp::value(&x, "pdfaid:part");
let conf = xmp::value(&x, "pdfaid:conformance");
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
issue("6.6.4", format!("The metadata doesn't identify the file as {}", level.label()), None, true);
}
if let Some(title) = info(doc, "Title")
&& xmp::value(&x, "dc:title").is_some_and(|t| t != title)
{
issue("6.6.3", "The title in the metadata and the document information differ".into(), None, true);
}
}
}
// 6.1.3: encryption.
if doc.trailer().get(b"Encrypt").is_some() {
issue("6.1.3", "The document is encrypted".into(), None, true);
}
// 6.2.2: output intent when device colour is used.
let objs = objects(doc);
let (rgb, cmyk) = uses_device_colour(doc, &objs);
if (rgb || cmyk) && !has_pdfa_intent(doc) {
issue("6.2.3", "Device colour is used, but there is no PDF/A output intent".into(), None, !cmyk);
if cmyk {
issue("6.2.4.3", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)".into(), None, false);
}
}
// 6.1.3 / 6.6.1: catalog actions.
if cat.get(b"AA").is_some() {
issue("6.5.2", "The document has additional actions (/AA)".into(), None, true);
}
if let Some(names) = cat.get(b"Names").map(|n| doc.resolve(n))
&& names.as_dict().is_some_and(|n| n.get(b"JavaScript").is_some())
{
issue("6.5.1", "The document has document-level JavaScript".into(), None, true);
}
if let Some(af) = cat.get(b"AcroForm").map(|a| doc.resolve(a))
&& af.as_dict().is_some_and(|a| matches!(a.get(b"NeedAppearances"), Some(Object::Bool(true))))
{
issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)".into(), None, true);
}
// Pages: annotations and page actions.
let pages = printcraft_model::pages(doc);
let page_of = |r: ObjRef| {
pages.iter().position(|p| {
p.dict
.get(b"Annots")
.map(|a| doc.resolve(a))
.and_then(|a| a.as_array().cloned())
.unwrap_or_default()
.iter()
.any(|x| x.as_ref() == Some(r))
})
};
for (i, p) in pages.iter().enumerate() {
if p.dict.get(b"AA").is_some() {
issue("6.5.2", format!("Page {} has additional actions", i + 1), Some(i), true);
}
}
for (r, o) in &objs {
let Some(d) = dict_of(o) else { continue };
let ty = d.name(b"Type");
let sub = d.name(b"Subtype");
// Actions anywhere (structure elements also have /S: their role).
if let Some(s) = d.name(b"S")
&& FORBIDDEN_ACTIONS.contains(&s)
&& is_action(d)
{
issue("6.5.1", format!("A {} action isn't allowed", String::from_utf8_lossy(s)), None, true);
}
// Annotations.
if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) {
let page = page_of(*r);
let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default();
if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
issue("6.3.1", format!("{name} annotations aren't allowed"), page, true);
continue;
}
let f = d.int(b"F").unwrap_or(0);
if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) {
issue("6.3.2", format!("A {name} annotation isn't set to print, or is hidden"), page, true);
}
let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some());
if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) {
let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| {
let v: Vec<f64> = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect();
v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6
});
if !zero {
issue("6.3.3", format!("A {name} annotation has no appearance stream"), page, false);
}
}
if d.get(b"AA").is_some() {
issue("6.5.2", format!("A {name} annotation has additional actions"), page, true);
}
}
// Form fields with additional actions (non-widget fields).
if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") {
issue("6.5.2", "A form field has additional actions".into(), None, true);
}
// Fonts.
if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() {
check_font(doc, d, &mut issue);
}
// Streams.
if let Object::Stream(s) = &**o {
let filters: Vec<Vec<u8>> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) {
Some(f) => match &*f {
Object::Name(n) => vec![n.clone()],
Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(),
_ => Vec::new(),
},
None => Vec::new(),
};
if filters.iter().any(|f| f == b"LZWDecode") {
issue("6.1.7.2", "A stream uses LZW compression".into(), None, false);
}
if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() {
issue("6.1.7.1", "A stream refers to an external file".into(), None, false);
}
if sub == Some(b"Image") {
if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) {
issue("6.2.8", "An image asks to be interpolated".into(), None, true);
}
if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() {
issue("6.2.8", "An image has alternates or OPI information".into(), None, true);
}
}
if sub == Some(b"PS") {
issue("6.2.9", "PostScript XObjects aren't allowed".into(), None, false);
}
}
// Embedded files.
if ty == Some(b"Filespec") && d.get(b"EF").is_some() {
match level {
Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)".into(), None, false),
Level::A3b => {
if d.get(b"AFRelationship").is_none() {
issue("6.8", "An embedded file has no AFRelationship".into(), None, true);
}
}
}
}
}
// One message per kind of problem is enough for repeated ones (as preflight summaries do).
let mut seen = std::collections::HashSet::new();
out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page)));
out
}
fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, String, Option<usize>, bool)) {
let sub = d.name(b"Subtype");
if sub == Some(b"Type3") {
return;
}
let name = d.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "a font".into());
let descriptor = if sub == Some(b"Type0") {
d.get(b"DescendantFonts")
.map(|x| doc.resolve(x))
.and_then(|a| a.as_array().and_then(|a| a.first().cloned()))
.map(|f| doc.resolve(&f))
.and_then(|f| f.as_dict().and_then(|fd| fd.get(b"FontDescriptor").cloned()))
} else {
d.get(b"FontDescriptor").cloned()
};
let embedded = descriptor
.map(|fd| doc.resolve(&fd))
.and_then(|fd| fd.as_dict().cloned())
.is_some_and(|fd| fd.get(b"FontFile").is_some() || fd.get(b"FontFile2").is_some() || fd.get(b"FontFile3").is_some());
if !embedded {
issue("6.2.11.4", format!("The font {name} isn't embedded"), None, false);
}
}
fn info(doc: &Document, key: &str) -> Option<String> {
let i = doc.trailer().get(b"Info").map(|i| doc.resolve(i))?;
i.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text()))
}
/// What [`convert`] did.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Report {
pub fixed: Vec<String>,
/// Problems left (the document isn't conforming while any remain).
pub remaining: Vec<Issue>,
}
/// Whether a dictionary with an `/S` entry is an action (not a structure element or the like).
fn is_action(d: &Dict) -> bool {
match d.name(b"Type") {
Some(t) => t == b"Action",
None => !(d.get(b"P").is_some() && d.get(b"K").is_some()),
}
}
/// Whether `o` is (or refers to) a forbidden action, or to nothing.
fn forbidden(doc: &Document, o: &Object) -> bool {
match &*doc.resolve(o) {
Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)),
Object::Null => true,
_ => false,
}
}
fn strip_actions(doc: &Document, d: &mut Dict) -> bool {
let mut changed = d.remove(b"AA").is_some();
if d.get(b"A").is_some_and(|a| forbidden(doc, a)) {
d.remove(b"A");
changed = true;
}
changed
}
/// Make `doc` conform to `level` as far as possible, then re-check it.
pub fn convert(doc: &mut Document, level: Level) -> Result<Report, printcraft_cos::CosError> {
let mut fixed = Vec::new();
if doc.trailer().get(b"Encrypt").is_some() {
doc.remove_encryption();
fixed.push("Removed encryption".into());
}
let root = doc.root().ok_or(printcraft_cos::CosError::Syntax { offset: 0, detail: "no catalog".into() })?;
// Catalog: actions, JavaScript, NeedAppearances, output intent, metadata.
let mut cat = catalog(doc);
if cat.remove(b"AA").is_some() {
fixed.push("Removed the document's additional actions".into());
}
if let Some(oa) = cat.get(b"OpenAction").cloned()
&& matches!(&*doc.resolve(&oa), Object::Dict(_))
&& forbidden(doc, &oa)
{
cat.remove(b"OpenAction");
fixed.push("Removed the open action".into());
}
if let Some(mut names) = cat.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_dict().cloned())
&& names.remove(b"JavaScript").is_some()
{
cat.set(b"Names".to_vec(), Object::Dict(names));
fixed.push("Removed document-level JavaScript".into());
}
if let Some(Object::Ref(af)) = cat.get(b"AcroForm").cloned() {
let mut changed = false;
doc.update_dict(af, |a| changed = a.remove(b"NeedAppearances").is_some())?;
if changed {
fixed.push("Cleared NeedAppearances".into());
}
} else if let Some(Object::Dict(mut af)) = cat.get(b"AcroForm").cloned()
&& af.remove(b"NeedAppearances").is_some()
{
cat.set(b"AcroForm".to_vec(), Object::Dict(af));
fixed.push("Cleared NeedAppearances".into());
}
if !has_pdfa_intent(doc) {
let mut icc = Dict::new();
icc.set(b"N".to_vec(), Object::Int(3));
let profile = doc.add(Object::Stream(Stream::flate(icc, &icc::srgb())));
let mut oi = Dict::new();
oi.set(b"Type".to_vec(), Object::name("OutputIntent"));
oi.set(b"S".to_vec(), Object::name("GTS_PDFA1"));
oi.set(b"OutputConditionIdentifier".to_vec(), PdfString::text("sRGB IEC61966-2.1"));
oi.set(b"Info".to_vec(), PdfString::text("sRGB IEC61966-2.1"));
oi.set(b"DestOutputProfile".to_vec(), Object::Ref(profile));
let mut list = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
list.push(Object::Dict(oi));
cat.set(b"OutputIntents".to_vec(), Object::Array(list));
fixed.push("Added an sRGB output intent".into());
}
let x = xmp::packet(doc, level);
let mut md = Dict::new();
md.set(b"Type".to_vec(), Object::name("Metadata"));
md.set(b"Subtype".to_vec(), Object::name("XML"));
// Metadata stays uncompressed so other tools can find it.
let m = doc.add(Object::Stream(Stream::from_raw(md, x.into_bytes())));
cat.set(b"Metadata".to_vec(), Object::Ref(m));
fixed.push(format!("Identified the file as {} in its XMP metadata", level.label()));
doc.update_dict(root, |c| *c = cat)?;
// Pages, annotations, fields, images, embedded files.
let (mut actions, mut flags, mut images, mut removed, mut files) = (0, 0, 0, 0, 0);
for p in printcraft_model::pages(doc) {
let mut changed = false;
doc.update_dict(p.obj, |d| changed = d.remove(b"AA").is_some())?;
actions += changed as usize;
}
for (r, o) in objects(doc) {
let Some(d) = dict_of(&o).cloned() else { continue };
let ty = d.name(b"Type").map(<[u8]>::to_vec);
let sub = d.name(b"Subtype").map(<[u8]>::to_vec);
let is_annot = ty.as_deref() == Some(b"Annot");
if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
// Take it off its page.
for p in printcraft_model::pages(doc) {
let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
if annots.iter().any(|a| a.as_ref() == Some(r)) {
let kept: Vec<Object> = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect();
doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?;
}
}
removed += 1;
continue;
}
if matches!(&*o, Object::Dict(_)) && (is_annot || d.get(b"FT").is_some()) {
let mut nd = d.clone();
let mut changed = strip_actions(doc, &mut nd);
actions += changed as usize;
if is_annot && sub.as_deref() != Some(b"Popup") {
let f = nd.int(b"F").unwrap_or(0);
let want = (f | 4) & !(1 | 2 | 32);
if want != f {
nd.set(b"F".to_vec(), Object::Int(want));
flags += 1;
changed = true;
}
}
if changed {
doc.set(r, Object::Dict(nd));
}
continue;
}
if let Object::Stream(s) = &*o
&& sub.as_deref() == Some(b"Image")
{
let mut ns = s.clone();
let mut changed = false;
if matches!(ns.dict.get(b"Interpolate"), Some(Object::Bool(true))) {
ns.dict.set(b"Interpolate".to_vec(), Object::Bool(false));
changed = true;
}
changed |= ns.dict.remove(b"Alternates").is_some();
changed |= ns.dict.remove(b"OPI").is_some();
if changed {
doc.set(r, Object::Stream(ns));
images += 1;
}
continue;
}
if level == Level::A3b && ty.as_deref() == Some(b"Filespec") && d.get(b"EF").is_some() && d.get(b"AFRelationship").is_none() {
let mut nd = d.clone();
nd.set(b"AFRelationship".to_vec(), Object::name("Unspecified"));
doc.set(r, Object::Dict(nd));
files += 1;
}
}
// Forbidden actions left as objects of their own (nothing refers to them any more).
for (r, o) in objects(doc) {
if let Object::Dict(d) = &*o
&& d.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s))
&& is_action(d)
{
doc.set(r, Object::Null);
actions += 1;
}
}
for (n, what) in [
(actions, "removed forbidden or additional actions"),
(flags, "set annotations to print and show"),
(images, "cleared image interpolation and alternates"),
(removed, "removed multimedia annotations"),
(files, "marked embedded files' relationship"),
] {
if n > 0 {
fixed.push(format!("{n}: {what}"));
}
}
let remaining = verify(doc, level);
Ok(Report { fixed, remaining })
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,102 @@
use super::*;
fn build(objs: &[&str], trailer_extra: &str) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
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());
Document::open(std::sync::Arc::new(out)).unwrap()
}
/// A page with embedded-font-free text, a note without the print flag, a link with a
/// JavaScript action, an interpolated image, and a document JavaScript.
fn messy() -> Document {
build(
&[
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(x) 9 0 R] >> >> /AA << /WC 9 0 R >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im 8 0 R >> >> /Annots [6 0 R 7 0 R] >>",
"<< /Length 44 >>\nstream\n1 0 0 rg BT /F1 12 Tf 10 10 Td (Hi) Tj ET /Im Do\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Link /Rect [40 10 80 30] /P 3 0 R /F 4 /A 9 0 R >>",
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Length 1 >>\nstream\n\u{0}\nendstream",
"<< /Type /Action /S /JavaScript /JS (app.alert(1)) >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream",
],
"/Info << /Title (Report) /Producer (Test) /CreationDate (D:20240105093000Z) >>",
)
}
fn clauses(issues: &[Issue]) -> Vec<&'static str> {
let mut c: Vec<&str> = issues.iter().map(|i| i.clause).collect();
c.dedup();
c
}
#[test]
fn verify_reports_what_breaks_pdfa() {
let issues = verify(&messy(), Level::A2b);
let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
for want in [
"The document has no XMP metadata",
"Device colour is used, but there is no PDF/A output intent",
"The document has additional actions (/AA)",
"The document has document-level JavaScript",
"A JavaScript action isn't allowed",
"A Text annotation isn't set to print, or is hidden",
"The font Helvetica isn't embedded",
"An image asks to be interpolated",
] {
assert!(msgs.contains(&want), "missing {want:?} in {msgs:#?}");
}
assert!(issues.iter().find(|i| i.message.starts_with("The font")).is_some_and(|i| !i.fixable));
assert!(clauses(&issues).contains(&"6.6.2.1"));
}
#[test]
fn convert_fixes_all_but_fonts() {
let mut doc = messy();
let r = convert(&mut doc, Level::A2b).unwrap();
assert!(r.fixed.iter().any(|f| f.contains("output intent")), "{:?}", r.fixed);
let left: Vec<&str> = r.remaining.iter().map(|i| i.message.as_str()).collect();
assert_eq!(left, ["The font Helvetica isn't embedded"], "{:#?}", r.remaining);
let d = declared(&doc);
assert_eq!(d.pdfa, Some((2, "B".into())));
assert_eq!(d.output_intents, ["sRGB IEC61966-2.1"]);
// The metadata carries the document information.
let x = metadata(&doc).unwrap();
assert_eq!(xmp::value(&x, "dc:title").as_deref(), Some("Report"));
assert_eq!(xmp::value(&x, "xmp:CreateDate").as_deref(), Some("2024-01-05T09:30:00Z"));
// It survives a save.
let bytes = printcraft_cos::write_full(&doc, &Default::default()).unwrap();
let back = Document::open(std::sync::Arc::new(bytes)).unwrap();
assert_eq!(verify(&back, Level::A2b).len(), 1);
}
#[test]
fn embedded_files_differ_between_parts() {
let doc = build(
&[
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(a.txt) 4 0 R] >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Filespec /F (a.txt) /EF << /F 5 0 R >> >>",
"<< /Type /EmbeddedFile /Length 2 >>\nstream\nhi\nendstream",
],
"",
);
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.8" && !i.fixable));
let mut d3 = doc.clone();
assert!(verify(&d3, Level::A3b).iter().any(|i| i.message.contains("AFRelationship")));
let r = convert(&mut d3, Level::A3b).unwrap();
assert!(r.remaining.is_empty(), "{:#?}", r.remaining);
}

135
crates/preflight/src/xmp.rs Normal file
View File

@ -0,0 +1,135 @@
//! XMP metadata for PDF/A: the identification schema and the document information, kept
//! consistent (ISO 19005-2 §6.6).
use printcraft_cos::Document;
/// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or
/// as an element (`<pdfaid:part>2</pdfaid:part>`, or inside an `rdf:Alt`/`rdf:Seq`).
pub fn value(xmp: &str, key: &str) -> Option<String> {
let attr = format!("{key}=\"");
if let Some(i) = xmp.find(&attr) {
let rest = &xmp[i + attr.len()..];
return rest.find('"').map(|e| unescape(&rest[..e]));
}
let attr1 = format!("{key}='");
if let Some(i) = xmp.find(&attr1) {
let rest = &xmp[i + attr1.len()..];
return rest.find('\'').map(|e| unescape(&rest[..e]));
}
let open = format!("<{key}>");
let close = format!("</{key}>");
let start = xmp.find(&open)? + open.len();
let end = start + xmp[start..].find(&close)?;
let inner = &xmp[start..end];
// rdf:Alt / rdf:Seq: the first rdf:li.
if let Some(li) = inner.find("<rdf:li") {
let s = li + inner[li..].find('>')? + 1;
let e = s + inner[s..].find("</rdf:li>")?;
return Some(unescape(inner[s..e].trim()));
}
Some(unescape(inner.trim()))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// A PDF date (`D:YYYYMMDDHHmmSSOHH'mm'`) as an XMP (ISO 8601) date.
pub fn iso_date(pdf: &str) -> Option<String> {
let d = pdf.trim().trim_start_matches("D:");
let digits: String = d.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() < 4 {
return None;
}
let part = |a: usize, b: usize, def: &str| digits.get(a..b).unwrap_or(def).to_string();
let (y, mo, da, h, mi, s) = (part(0, 4, "0000"), part(4, 6, "01"), part(6, 8, "01"), part(8, 10, "00"), part(10, 12, "00"), part(12, 14, "00"));
let rest = &d[digits.len()..];
let tz = match rest.chars().next() {
Some('Z') | None => "Z".to_string(),
Some(sign @ ('+' | '-')) => {
let t: String = rest[1..].chars().filter(|c| c.is_ascii_digit()).collect();
format!("{sign}{}:{}", t.get(0..2).unwrap_or("00"), t.get(2..4).unwrap_or("00"))
}
_ => "Z".to_string(),
};
Some(format!("{y}-{mo}-{da}T{h}:{mi}:{s}{tz}"))
}
fn info(doc: &Document, key: &str) -> Option<String> {
let i = doc.trailer().get(b"Info").map(|i| doc.resolve(i))?;
i.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).filter(|s| !s.is_empty())
}
/// The XMP packet for `doc` declaring `level`, from its document information.
pub fn packet(doc: &Document, level: crate::Level) -> String {
let mut props = String::new();
if let Some(t) = info(doc, "Title") {
props.push_str(&format!(" <dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>\n", esc(&t)));
}
if let Some(a) = info(doc, "Author") {
props.push_str(&format!(" <dc:creator><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></dc:creator>\n", esc(&a)));
}
if let Some(s) = info(doc, "Subject") {
props.push_str(&format!(" <dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:description>\n", esc(&s)));
}
if let Some(k) = info(doc, "Keywords") {
props.push_str(&format!(" <pdf:Keywords>{}</pdf:Keywords>\n", esc(&k)));
}
if let Some(p) = info(doc, "Producer") {
props.push_str(&format!(" <pdf:Producer>{}</pdf:Producer>\n", esc(&p)));
}
if let Some(c) = info(doc, "Creator") {
props.push_str(&format!(" <xmp:CreatorTool>{}</xmp:CreatorTool>\n", esc(&c)));
}
if let Some(d) = info(doc, "CreationDate").and_then(|d| iso_date(&d)) {
props.push_str(&format!(" <xmp:CreateDate>{d}</xmp:CreateDate>\n"));
}
if let Some(d) = info(doc, "ModDate").and_then(|d| iso_date(&d)) {
props.push_str(&format!(" <xmp:ModifyDate>{d}</xmp:ModifyDate>\n"));
}
format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\"\n\
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n\
xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"\n\
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"\n\
xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
<pdfaid:part>{}</pdfaid:part>\n\
<pdfaid:conformance>B</pdfaid:conformance>\n\
{props} </rdf:Description>\n\
</rdf:RDF>\n\
</x:xmpmeta>\n\
<?xpacket end=\"w\"?>",
level.part()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_attribute_and_element_forms() {
let a = "<rdf:Description pdfaid:part=\"2\" pdfaid:conformance='B'/>";
assert_eq!(value(a, "pdfaid:part").as_deref(), Some("2"));
assert_eq!(value(a, "pdfaid:conformance").as_deref(), Some("B"));
let e = "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">A &amp; B</rdf:li></rdf:Alt></dc:title><pdfaid:part>3</pdfaid:part>";
assert_eq!(value(e, "dc:title").as_deref(), Some("A & B"));
assert_eq!(value(e, "pdfaid:part").as_deref(), Some("3"));
assert_eq!(value(e, "pdfuaid:part"), None);
}
#[test]
fn pdf_dates_become_iso_dates() {
assert_eq!(iso_date("D:20240105093000+01'00'").as_deref(), Some("2024-01-05T09:30:00+01:00"));
assert_eq!(iso_date("D:20240105").as_deref(), Some("2024-01-05T00:00:00Z"));
assert_eq!(iso_date("D:20240105120000Z").as_deref(), Some("2024-01-05T12:00:00Z"));
assert_eq!(iso_date("junk"), None);
}
}

View File

@ -374,6 +374,10 @@ impl PrintCraftApp {
}
"form.detect" => self.detect_fields(),
"doc.compare" => self.dialog = Some(Dialog::CompareFiles),
"standards.pdfa" => {
self.pdfa.issues = None;
self.dialog = Some(Dialog::PdfA);
}
"actions.wizard" | "actions.distribution" | "actions.optimize_scans" => {
self.wizard.editing = None;
match id {

View File

@ -820,6 +820,10 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
close = go || cancel;
return;
}
Dialog::PdfA => {
close = crate::standards_ui::body(ui, app, &t);
return;
}
Dialog::ActionWizard => {
ui.set_width(620.0);
close = crate::actions_ui::body(ui, app, &t);

View File

@ -26,6 +26,7 @@ mod optimize_ui;
mod search_ui;
mod sign_ui;
mod stamps_ui;
mod standards_ui;
mod zoom_snap;
/// Header & footer / watermark / background dialog types (tests and automation).
pub mod marks {
@ -202,6 +203,8 @@ pub enum Dialog {
CompareFiles,
/// Action Wizard.
ActionWizard,
/// Standards ▸ PDF/A.
PdfA,
/// Combine files: the files, their order and pages.
Combine,
/// Custom stamps ▸ Create.
@ -324,6 +327,8 @@ pub struct PrintCraftApp {
pub wizard: actions_ui::Wizard,
pub action_run: Option<std::sync::Arc<std::sync::Mutex<actions_ui::RunProgress>>>,
pub action_files_override: Option<Vec<String>>,
/// Standards ▸ PDF/A: level and last result.
pub pdfa: standards_ui::PdfaState,
/// Compare files: the chosen older document and the last result.
pub compare_old: Option<DocId>,
pub compare: Option<compare_ui::CompareState>,
@ -473,6 +478,7 @@ impl PrintCraftApp {
wizard: Default::default(),
action_run: None,
action_files_override: None,
pdfa: Default::default(),
compare_old: None,
compare: None,
js_console: Default::default(),
@ -927,6 +933,7 @@ impl PrintCraftApp {
"preferences" => Some(Dialog::Preferences),
"compare-files" => Some(Dialog::CompareFiles),
"action-wizard" => Some(Dialog::ActionWizard),
"pdfa" => Some(Dialog::PdfA),
"signature" => Some(Dialog::Signature),
"optimize" => Some(Dialog::Optimize),
"sign" | "certify" => {

View File

@ -0,0 +1,114 @@
//! Standards ▸ PDF/A: what the document declares, verifying it against PDF/A-2b or 3b, and
//! fixing what can be fixed (Save as PDF/A).
use egui::{Align, Layout};
use printcraft_engine::pdfa::{Issue, Level};
use crate::theme::{self, Tokens};
use crate::{PrintCraftApp, widgets};
/// The dialog's state: the chosen level and the last result.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfaState {
pub level: Level,
pub issues: Option<Vec<Issue>>,
pub fixed: Vec<String>,
}
impl Default for PdfaState {
fn default() -> Self {
PdfaState { level: Level::A2b, issues: None, fixed: Vec::new() }
}
}
impl PrintCraftApp {
pub fn pdfa_verify(&mut self) {
let Some((_, id)) = self.active_ids() else { return };
let level = self.pdfa.level;
self.pdfa.issues = self.session.get(id).map(|d| d.pdfa_verify(level));
self.pdfa.fixed.clear();
}
pub fn pdfa_convert(&mut self) {
let level = self.pdfa.level;
if self.apply_edit(printcraft_engine::Edit::ConvertPdfA { level }) {
self.pdfa_verify();
let left = self.pdfa.issues.as_ref().map_or(0, Vec::len);
self.notify(if left == 0 {
format!("The document now conforms to {}; save it to keep the changes", level.label())
} else {
format!("Fixed what could be fixed; {left} problem{} remain", if left == 1 { "" } else { "s" })
});
}
}
}
/// Returns `true` to close.
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool {
ui.label(egui::RichText::new("PDF/A").font(theme::semibold(18.0)));
ui.add_space(6.0);
let declared = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.standards()).unwrap_or_default();
let shown = declared.pdfa.as_ref().map_or("none".to_string(), |(p, c)| format!("PDF/A-{p}{}", c.to_lowercase()));
ui.label(format!("Declared conformance: {shown}"));
if !declared.output_intents.is_empty() {
ui.label(egui::RichText::new(format!("Output intent: {}", declared.output_intents.join(", "))).color(t.text_muted));
}
ui.add_space(6.0);
let before = app.pdfa.level;
ui.horizontal(|ui| {
ui.label("Conformance level:");
egui::ComboBox::from_id_salt("pdfa-level").selected_text(app.pdfa.level.label()).show_ui(ui, |ui| {
for l in [Level::A2b, Level::A3b] {
ui.selectable_value(&mut app.pdfa.level, l, l.label());
}
});
});
if app.pdfa.level != before {
app.pdfa.issues = None;
}
ui.add_space(6.0);
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(ui.available_width());
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
ui.set_min_height(120.0);
match &app.pdfa.issues {
None => {
ui.label(egui::RichText::new("Verify to see what the document needs.").color(t.text_muted));
}
Some(v) if v.is_empty() => {
ui.label(format!("No problems found: the document conforms to {}.", app.pdfa.level.label()));
}
Some(v) => {
for i in v {
let page = i.page.map(|p| format!(" (page {})", p + 1)).unwrap_or_default();
let fix = if i.fixable { "" } else { " · not fixable here" };
ui.label(format!("{}{page}", i.message));
ui.label(egui::RichText::new(format!("ISO 19005 {}{fix}", i.clause)).small().color(t.text_muted));
}
}
}
});
});
ui.add_space(10.0);
let mut close = false;
let mut action = None;
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, "Save as PDF/A", true).clicked() {
action = Some(true);
}
if widgets::pill_button(ui, "Verify", false).clicked() {
action = Some(false);
}
if widgets::pill_button(ui, "Close", false).clicked() {
close = true;
}
});
});
match action {
Some(true) => app.pdfa_convert(),
Some(false) => app.pdfa_verify(),
None => {}
}
close
}

View File

@ -62,3 +62,27 @@ fn compare_panel_screenshot() {
}
h.render().unwrap().save(out).unwrap();
}
#[test]
fn pdfa_dialog_verifies_and_converts() {
let doc = Session::new().create_from_text("t", "Keep forever").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("keep.pdf", None, doc.clone()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("standards.pdfa"));
h.run_steps(2);
h.get_by_label("Declared conformance: none");
h.get_by_label("Verify").click();
h.run_steps(3);
h.get_by_label("The document has no XMP metadata");
h.get_by_label("Save as PDF/A").click();
h.run_steps(3);
h.get_by_label("Declared conformance: PDF/A-2b");
let issues = h.state().pdfa.issues.clone().unwrap();
assert!(issues.iter().all(|i| !i.fixable), "{issues:?}");
let id = h.state().active_ids().unwrap().1;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Save as PDF/A-2b"));
}

View File

@ -7607,11 +7607,11 @@ title = "Verify PDF/A compliance (1-4)"
area = "K"
tier = "P1"
milestone = "M11"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_verify"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent, fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
[[feature]]
id = "optimize.pdfa-convert"
@ -7619,11 +7619,11 @@ title = "Save as PDF/A (1b, 2b, 3b, 4)"
area = "K"
tier = "P1"
milestone = "M11"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_convert"]
evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "Fixes XMP, output intent (generated sRGB ICC profile), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1."
[[feature]]
id = "optimize.pdfa-accessible-levels"
@ -7667,11 +7667,11 @@ title = "Choose output intents and conditions"
area = "K"
tier = "P1"
milestone = "M11"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_convert", "pdfa_verify"]
evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "sRGB output intent added and listed; no choice of other conditions yet."
[[feature]]
id = "optimize.pdfe"
@ -7715,11 +7715,11 @@ title = "Standards panel showing declared conformance"
area = "K"
tier = "P1"
milestone = "M11"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_verify"]
evidence = ["crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "Declared PDF/A, PDF/UA and output intents shown in the PDF/A dialog and returned by pdfa_verify."
[[feature]]
id = "optimize.remove-standards-id"