From a5ac3aa05cf3ec013961a85599ddd9e0b5c03813 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 10:14:26 -0500 Subject: [PATCH] Merge pull request #1322 from storytold/codex/craft-xml-dtd prepress: share PDF/X-1a finalization and checks (official replacement) --- Cargo.lock | 81 ++- crates/pdf/Cargo.toml | 4 + crates/pdf/README.md | 4 + crates/pdf/src/write.rs | 2 + crates/pdf/src/write/svg.rs | 163 +++++ crates/prepress/src/lib.rs | 2 + crates/prepress/src/pdfx1a.rs | 772 ++++++++++++++++++++++ crates/prepress/tests/flatten_document.rs | 1 - crates/xml/Cargo.toml | 1 + crates/xml/src/dtd.rs | 748 +++++++++++++++++++++ crates/xml/src/lib.rs | 1 + 11 files changed, 1777 insertions(+), 2 deletions(-) create mode 100644 crates/pdf/src/write/svg.rs create mode 100644 crates/prepress/src/pdfx1a.rs create mode 100644 crates/xml/src/dtd.rs diff --git a/Cargo.lock b/Cargo.lock index a1b1ccab3..bef3f51da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2181,6 +2181,7 @@ dependencies = [ "bytemuck", "chrono", "craft-stream-codecs", + "craft-svg", "flate2", "hayro", "hayro-cmap", @@ -2188,6 +2189,7 @@ dependencies = [ "hayro-syntax", "js-sys", "krilla", + "krilla-svg", "kurbo 0.13.1", "md-5 0.11.0", "proptest", @@ -2197,6 +2199,7 @@ dependencies = [ "stringprep", "thiserror 2.0.21", "ttf-parser", + "usvg 0.47.0", "wasm-bindgen", "wasm-bindgen-test", "zeroize", @@ -2820,6 +2823,7 @@ name = "craft-xml" version = "0.1.0" dependencies = [ "quick-xml 0.41.0", + "serde", "thiserror 2.0.21", ] @@ -4053,6 +4057,20 @@ dependencies = [ "roxmltree 0.20.0", ] +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "fontdb" version = "0.24.0" @@ -5648,6 +5666,22 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "krilla-svg" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16" +dependencies = [ + "flate2", + "fontdb 0.23.0", + "krilla", + "resvg 0.47.0", + "skrifa 0.42.1", + "smallvec", + "tiny-skia 0.12.0", + "usvg 0.47.0", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -7855,6 +7889,23 @@ dependencies = [ "usvg 0.45.1", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.2", + "tiny-skia 0.12.0", + "usvg 0.47.0", + "zune-jpeg", +] + [[package]] name = "resvg" version = "0.48.1" @@ -9901,6 +9952,34 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize 0.14.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.2", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.16.2", + "tiny-skia-path 0.12.0", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.48.1" @@ -9909,7 +9988,7 @@ checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" dependencies = [ "base64 0.23.1", "data-url", - "fontdb", + "fontdb 0.24.0", "harfrust 0.12.0", "imagesize 0.15.0", "kurbo 0.13.1", diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index ff94c6d8e..266297f42 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -17,6 +17,7 @@ interpret = ["read", "dep:hayro-interpret"] paths = ["interpret", "dep:kurbo"] raster = ["interpret", "dep:hayro", "dep:bytemuck"] write = ["dep:krilla", "dep:thiserror"] +write-svg = ["write", "dep:craft-svg", "dep:krilla-svg", "dep:usvg", "craft-svg/tree-text"] write-pdf = ["write", "krilla/pdf"] write-raster = ["dep:flate2", "dep:roxmltree"] write-simple-text = ["write", "krilla/simple-text"] @@ -30,6 +31,9 @@ hayro-interpret = { workspace = true, optional = true } hayro = { workspace = true, optional = true } bytemuck = { workspace = true, optional = true } krilla = { workspace = true, optional = true } +craft-svg = { workspace = true, optional = true } +krilla-svg = { version = "=0.8.1", optional = true } +usvg = { version = "=0.47.0", features = ["text"], optional = true } thiserror = { workspace = true, optional = true } flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"], optional = true } roxmltree = { version = "0.21", optional = true } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 18d3d25a9..8cd2c3f25 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -10,6 +10,7 @@ No feature is enabled by default. | `interpret` | `read` plus an app-owned Device interpreter, fonts and CMaps | | `raster` | `interpret` plus premultiplied RGBA8 rendering | | `write` | Guarded Krilla authoring; no shared reader dependency | +| `write-svg` | `write` plus bounded SVG-to-PDF vector drawing | | `write-pdf` | `write` plus Krilla's embedded-PDF support | | `write-simple-text` | `write` plus Krilla's optional text shaper and `draw_text` | | `filters` | The `/Filter` chain for editing and writing PDF files: decode and encode, with limits and accounting. No reader or writer dependency | @@ -240,6 +241,9 @@ serialization uses the same backend version as before extraction. It provides: refuses it. Apps build URI links with it instead of `backend::action::LinkAction`, so a link target from someone else's document becomes a PDF link only when it is an `http`/`https` URL with a host or a `mailto` address. A refused target writes no link annotation. +- `write::svg` (feature `write-svg`): bounded SVG/SVGZ text preflight, data-only image + resolution, and vector drawing through krilla-svg. Callers provide font and fallback options; + the adapter enforces input, nesting, embedded-image, destination-area and filter budgets. - `safe_url` (crate root, any feature): the same rule, for apps that open links themselves, such as a slide show. diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 8114327d4..924910e25 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -15,6 +15,8 @@ const _: bool = backend::CRAFT_PRINT_COLOR; const _: bool = backend::CRAFT_OPTIONAL_CONTENT; pub mod print; +#[cfg(feature = "write-svg")] +pub mod svg; use backend::configure::{Archival, Configuration, ConfigurationBuilder, ConfigurationError, PdfVersion}; use backend::text::{Font, Tag}; diff --git a/crates/pdf/src/write/svg.rs b/crates/pdf/src/write/svg.rs new file mode 100644 index 000000000..2eaf055ec --- /dev/null +++ b/crates/pdf/src/write/svg.rs @@ -0,0 +1,163 @@ +//! Bounded SVG parsing and vector drawing on a PDF surface. +//! +//! This adapter owns the usvg version required by krilla-svg. Callers pass their font and +//! fallback options, while this module checks input, embedded SVGs, and output/filter budgets +//! before converting SVG to PDF drawing operations. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use krilla_svg::SurfaceExt; + +pub use krilla_svg::SvgSettings; +pub use usvg; + +/// Resource limits for parsing SVG and drawing it to PDF. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + /// Maximum decoded bytes in one SVG document. + pub input_bytes: usize, + /// Maximum SVG element and reference depth. + pub depth: usize, + /// Maximum XML nodes in one document. + pub xml_nodes: u32, + /// Maximum total decoded bytes of embedded SVGs. + pub embedded_svg_bytes: usize, + /// Maximum number of embedded SVG documents nested within one another. + pub embedded_svg_depth: usize, + /// Maximum encoded bytes retained for embedded raster images. + pub embedded_image_bytes: usize, + /// Maximum PDF surface area, measured in 96 dpi pixels. + pub vector_pixels: u64, + /// Maximum raster pixels used by filters after `filter_scale` is applied. + pub filter_pixels: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + input_bytes: 16 * 1024 * 1024, + depth: 64, + xml_nodes: 100_000, + embedded_svg_bytes: 32 * 1024 * 1024, + embedded_svg_depth: 4, + embedded_image_bytes: 32 * 1024 * 1024, + vector_pixels: 256_000_000, + filter_pixels: 256_000_000, + } + } +} + +/// Why an SVG could not be parsed or drawn within its limits. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The bounded SVG text/XML preflight refused the input. + #[error("SVG preflight failed: {0}")] + Preflight(String), + /// usvg could not construct the SVG tree. + #[error("SVG parsing failed: {0}")] + Parse(String), + /// The requested PDF area or filter raster exceeds its budget. + #[error("SVG drawing exceeds the PDF area or filter budget")] + DrawLimit, + /// krilla-svg refused to draw the parsed tree. + #[error("krilla-svg could not draw the parsed tree")] + Draw, +} + +/// An SVG tree parsed within [`Limits`], ready to draw as PDF vectors. +pub struct Document { + tree: usvg::Tree, + limits: Limits, +} + +impl Document { + /// Parse SVG/SVGZ bytes after bounded shared preflight. `options` controls the caller's font + /// database, family selection, and fallback policy; its file-reading and image resolvers are + /// replaced with a data-only resolver governed by `limits`. + pub fn parse<'a>(bytes: &[u8], limits: Limits, mut options: usvg::Options<'a>) -> Result { + let text = bounded_text(bytes, limits.input_bytes, limits).map_err(|e| Error::Preflight(e.to_string()))?; + options.image_href_resolver = image_resolver(limits); + let tree = usvg::Tree::from_str(&text, &options).map_err(|e| Error::Parse(e.to_string()))?; + Ok(Self { tree, limits }) + } + + /// Draw the parsed tree as PDF vectors. The limits account for the destination size and the + /// renderer's filter scale before krilla-svg allocates filter rasters. + pub fn draw(&self, surface: &mut krilla::surface::Surface<'_>, size: krilla::geom::Size, settings: SvgSettings) -> Result<(), Error> { + let width = f64::from(size.width()); + let height = f64::from(size.height()); + let scale = f64::from(settings.filter_scale); + if !width.is_finite() || !height.is_finite() || !scale.is_finite() || width <= 0.0 || height <= 0.0 || scale <= 0.0 { + return Err(Error::DrawLimit); + } + let area = width * height * (96.0_f64 / 72.0).powi(2); + let filter_area = area * scale * scale; + if !area.is_finite() || !filter_area.is_finite() || area > self.limits.vector_pixels as f64 || filter_area > self.limits.filter_pixels as f64 + { + return Err(Error::DrawLimit); + } + surface.draw_svg(&self.tree, size, settings).ok_or(Error::Draw) + } +} + +#[derive(Default)] +struct Usage { + svg_bytes: AtomicUsize, + svg_depth: AtomicUsize, + image_bytes: AtomicUsize, +} + +fn shared_limits(bytes: usize, limits: Limits) -> craft_svg::tree::Limits { + craft_svg::tree::Limits { + bytes, + depth: limits.depth, + refuse_entity_markup: true, + xml_nodes: limits.xml_nodes, + embedded_bytes: limits.embedded_svg_bytes, + embedded_depth: limits.embedded_svg_depth, + ..Default::default() + } +} + +fn bounded_text(bytes: &[u8], byte_limit: usize, limits: Limits) -> Result, craft_svg::tree::Error> { + craft_svg::tree::text(bytes, &shared_limits(byte_limit, limits)) +} + +fn reserve(counter: &AtomicUsize, amount: usize, limit: usize) -> bool { + counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |used| used.checked_add(amount).filter(|next| *next <= limit)).is_ok() +} + +fn embedded_svg(usage: &Usage, limits: Limits, bytes: &[u8], options: &usvg::Options<'_>) -> Option { + let used = usage.svg_bytes.load(Ordering::Relaxed); + let remaining = limits.embedded_svg_bytes.saturating_sub(used); + let text = bounded_text(bytes, remaining.min(limits.input_bytes), limits).ok()?; + if !reserve(&usage.svg_bytes, text.len(), limits.embedded_svg_bytes) { + return None; + } + let depth = usage.svg_depth.fetch_add(1, Ordering::Relaxed); + if depth >= limits.embedded_svg_depth { + usage.svg_depth.fetch_sub(1, Ordering::Relaxed); + return None; + } + let parsed = usvg::Tree::from_str(&text, options).ok(); + usage.svg_depth.fetch_sub(1, Ordering::Relaxed); + parsed.map(usvg::ImageKind::SVG) +} + +fn image_resolver(limits: Limits) -> usvg::ImageHrefResolver<'static> { + let usage = Arc::new(Usage::default()); + let default_data = usvg::ImageHrefResolver::default_data_resolver(); + usvg::ImageHrefResolver { + resolve_data: Box::new(move |mime, bytes, options| { + if mime == "image/svg+xml" || (mime == "text/plain" && (craft_svg::sniff::is_svg(&bytes) || craft_svg::sniff::is_svgz(&bytes))) { + return embedded_svg(&usage, limits, &bytes, options); + } + if !reserve(&usage.image_bytes, bytes.len(), limits.embedded_image_bytes) { + return None; + } + default_data(mime, bytes, options) + }), + resolve_string: Box::new(|_, _| None), + } +} diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index 3fa4f472e..1afc7cf6f 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -12,6 +12,8 @@ pub mod flatten; pub mod ink; pub mod marks; pub mod output; +#[cfg(feature = "flatten")] +pub mod pdfx1a; pub mod plates; pub use cms::{CmsConversion, ProfileError}; diff --git a/crates/prepress/src/pdfx1a.rs b/crates/prepress/src/pdfx1a.rs new file mode 100644 index 000000000..0755424be --- /dev/null +++ b/crates/prepress/src/pdfx1a.rs @@ -0,0 +1,772 @@ +//! PDF/X-1a:2003: the PDF/X-4 print file flattened by craft-prepress (no transparency left; RGB +//! and ICC color converted into the output intent's CMYK; inks, overprint and live text kept), +//! written by krilla as PDF 1.4, given the PDF/X-1a identification and output intent, then +//! checked. Apps use [`flatten_x1a`] to flatten and finalize in one step, and [`check_pdfx1a`] +//! to inspect an existing file. + +use std::collections::{HashMap, HashSet}; + +use crate::flatten::pdf::ReadOptions; +use crate::flatten::{FlattenDocumentOptions, FlattenOptions, FlattenTotals, flatten_document}; +use craft_pdf::syntax::content::UntypedIter; +use craft_pdf::syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream}; +use craft_pdf::syntax::page::Resources; +use craft_pdf::write::backend::configure::PdfVersion; + +const MAX_DEPTH: usize = 16; + +/// `GTS_PDFXVersion` of PDF/X-1a:2003 (ISO 15930-4): the base level, PDF/X-1:2003. +const VERSION: &str = "PDF/X-1:2003"; + +/// `GTS_PDFXConformance` of PDF/X-1a:2003 (ISO 15930-4): with [`VERSION`], it names the +/// conformance level. +const CONFORMANCE: &str = "PDF/X-1a:2003"; + +/// `/RegistryName` of the ICC's registry of characterized printing conditions. An output intent +/// naming one of them needs no `/DestOutputProfile` in PDF/X. +const ICC_REGISTRY: &str = "http://www.color.org"; + +/// The graphics placed on one output page, to name in errors: the page's name and the graphics' +/// names. +#[derive(Clone, Debug, Default)] +pub struct PageGraphics { + pub label: String, + pub graphics: Vec, +} + +/// The PDF/X-1a file for `pdf`, the PDF/X-4 print file: flattened at `dpi`, written at PDF 1.4, +/// identified, checked. `pages` names each output page and its graphics for errors. Fails when +/// the flattener fails or leaves an image out, when the output intent's profile can't go into a +/// PDF 1.4 file, or when the result still has transparency or color outside DeviceCMYK, +/// DeviceGray, Separation and DeviceN; other check findings become warnings. +pub fn flatten_x1a(pdf: &[u8], dpi: f32, pages: &[PageGraphics]) -> Result<(Vec, Vec), String> { + let dpi = if dpi.is_finite() { dpi.clamp(36.0, 2400.0) } else { 300.0 }; + let opts = FlattenDocumentOptions { + read: ReadOptions { raster_dpi: dpi, ..ReadOptions::default() }, + flatten: FlattenOptions { raster_dpi: dpi, ..FlattenOptions::default() }, + producer: "DesignCraft".into(), + version: PdfVersion::Pdf14, + pdfx_version: Some(VERSION.into()), + ..FlattenDocumentOptions::default() + }; + let (bytes, report) = flatten_document(pdf, &opts).map_err(|e| format!("PDF/X-1a: the transparency flattener failed: {e}"))?; + let unread: Vec = report + .pages + .iter() + .filter(|(_, r)| r.read.unreadable_images > 0) + .map(|(page, r)| { + let n = r.read.unreadable_images; + let images = if n == 1 { "1 image".to_string() } else { format!("{n} images") }; + let fallback = PageGraphics { label: page.saturating_add(1).to_string(), graphics: Vec::new() }; + let at = pages.get(*page).unwrap_or(&fallback); + let names: Vec = at.graphics.iter().take(5).map(|g| format!("`{g}`")).collect(); + let more = if at.graphics.len() > 5 { ", …" } else { "" }; + let placed = if names.is_empty() { String::new() } else { format!(" (graphics on it: {}{more})", names.join(", ")) }; + format!("{images} on page {}{placed}", at.label) + }) + .collect(); + if !unread.is_empty() { + return Err(format!( + "PDF/X-1a: the transparency flattener couldn't read {}, and a PDF/X file can't leave images out: replace the graphic, or export without PDF/X-1a", + unread.join("; ") + )); + } + let mut warnings = flatten_warnings(&report.totals, dpi); + let bytes = finish_x1a(&bytes)?; + let (fatal, other) = checks(&bytes); + if !fatal.is_empty() { + return Err(format!("PDF/X-1a: after flattening, {}", fatal.join("; "))); + } + warnings.extend(other.into_iter().map(|issue| format!("PDF/X-1a: {issue}"))); + Ok((bytes, warnings)) +} + +/// What the flattener's `t` (at `dpi`) tells the user: rasterized transparency, and spot inks past +/// the ink set's capacity, whose tints the flattener drops. +pub fn flatten_warnings(t: &FlattenTotals, dpi: f32) -> Vec { + let mut out = Vec::new(); + if t.complex_regions > 0 || t.complex_groups > 0 { + let at = t.lowest_dpi.map(|d| format!(", some below {dpi:.0} ppi (lowest {d:.0} ppi)")).unwrap_or_default(); + out.push(format!("PDF/X-1a: transparency flattened: {} region(s) rasterized at {dpi:.0} ppi{at}", t.complex_regions + t.complex_groups)); + } + if t.dropped_inks > 0 { + out.push(format!( + "PDF/X-1a: the document has more spot inks than the flattener can separate: {} tint(s) in the inks past its limit were dropped and won't print", + t.dropped_inks + )); + } + out +} + +/// PDF/X-1a:2003 checks on an exported file (empty = passes them): PDF 1.4 or lower, the +/// identification (`GTS_PDFXVersion` and `GTS_PDFXConformance`), a CMYK `GTS_PDFX` output intent +/// whose profile, when it has one, is ICC v2 (and without one, names a registered printing +/// condition), `/Trapped` True or False, trim boxes, no encryption or interpolation; no +/// transparency (soft masks, transparency groups, constant opacity below 1, blend modes other +/// than Normal); only DeviceCMYK, DeviceGray, Separation and DeviceN (and Indexed and Pattern +/// spaces over them), a spot's alternate space process; every font embedded. +pub fn check_pdfx1a(pdf: &[u8]) -> Vec { + let (mut issues, other) = checks(pdf); + issues.extend(other); + issues +} + +/// [`check_pdfx1a`]'s findings: transparency and color spaces first, then the rest. +fn checks(pdf: &[u8]) -> (Vec, Vec) { + let mut issues = Vec::new(); + let text = String::from_utf8_lossy(pdf); + let file = craft_pdf::syntax::Pdf::new(pdf.to_vec()).ok(); + if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'4') { + issues.push("the PDF version must be 1.4 or lower".into()); + } + if file.as_ref().is_none_or(|f| pdfx_intents(f).is_empty()) { + issues.push("no PDF/X output intent".into()); + } + let info = |key: &[u8]| file.as_ref().and_then(|f| info_text(pdf, f, key)); + if info(b"GTS_PDFXVersion").as_deref() != Some(VERSION) { + issues.push(format!("the document information doesn't name {VERSION}")); + } + if info(b"GTS_PDFXConformance").as_deref() != Some(CONFORMANCE) { + issues.push(format!("the document information doesn't name the conformance level {CONFORMANCE}")); + } + if !text.contains("/Trapped/False") && !text.contains("/Trapped/True") && !text.contains("/Trapped /False") && !text.contains("/Trapped /True") { + issues.push("the document information needs /Trapped True or False".into()); + } + if text.contains("/Interpolate true") { + issues.push("an image asks for interpolation".into()); + } + if text.contains("/Encrypt") { + issues.push("the file is encrypted".into()); + } + let pages = text.matches("/Type/Page/").count() + text.matches("/Type/Page>").count(); + let trims = text.matches("/TrimBox").count(); + if pages > 0 && trims < pages { + issues.push("every page needs a trim box".into()); + } + let (content, fonts) = content(file.as_ref()); + issues.extend(fonts); + (content, issues) +} + +/// Transparency and color spaces, then the output intent and fonts, walked through every page's +/// content and what it uses. +fn content(file: Option<&craft_pdf::syntax::Pdf>) -> (Vec, Vec) { + let Some(file) = file else { + return (vec!["the file couldn't be read to check its transparency and color spaces".into()], Vec::new()); + }; + let mut fonts = intent_issues(file); + let mut found: Vec<(String, Vec)> = Vec::new(); + let mut font_found: Vec<(String, Vec)> = Vec::new(); + for (i, page) in file.pages().iter().enumerate() { + let mut scan = Scan::default(); + let res = page.resources(); + if let Some(group) = page.raw().get::>(b"Group") { + scan.group(&group); + } + if let Some(content) = page.page_stream() { + scan.content(content, res, 0); + } + for (list, items) in [(&mut found, scan.found), (&mut font_found, scan.font_issues)] { + for what in items { + match list.iter_mut().find(|(w, _)| *w == what) { + Some((_, pages)) => pages.push(i + 1), + None => list.push((what, vec![i + 1])), + } + } + } + } + let on_pages = |(what, pages): (String, Vec)| { + let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let more = if pages.len() > 10 { ", …" } else { "" }; + let s = if pages.len() == 1 { "" } else { "s" }; + format!("{what} on page{s} {}{more}", list.join(", ")) + }; + fonts.extend(font_found.into_iter().map(on_pages)); + (found.into_iter().map(on_pages).collect(), fonts) +} + +/// A `GTS_PDFX` output intent, as PDF/X-1a judges it. +struct Intent { + /// Its object number, when it is an indirect object. + obj: Option, + /// `/OutputCondition`, else `/OutputConditionIdentifier`, for messages. + name: String, + /// An `/OutputConditionIdentifier` other than `Custom` in the ICC registry. + registered: bool, + /// Whether it has a `/DestOutputProfile`, and the profile's object number. + has_profile: bool, + profile_obj: Option, + /// The profile's `/N` and decoded bytes. + channels: Option, + profile: Option>, +} + +impl Intent { + /// The profile's ICC major version (byte 8 of its header). + fn major(&self) -> Option { + self.profile.as_deref()?.get(8).copied() + } + + /// "ICC v4", or what keeps the version from being read. + fn version(&self) -> String { + self.major().map_or_else(|| "unreadable".to_string(), |v| format!("ICC v{v}")) + } +} + +/// The catalog's `GTS_PDFX` output intents. +fn pdfx_intents(file: &craft_pdf::syntax::Pdf) -> Vec { + let number = |n: i32| usize::try_from(n).ok(); + raw_pdfx_intents(file) + .into_iter() + .map(|oi| { + let text = |key: &[u8]| { + oi.get::>(key) + .map(|s| String::from_utf8_lossy(s.as_bytes()).trim().to_string()) + .filter(|s| !s.is_empty()) + }; + let identifier = text(b"OutputConditionIdentifier"); + let registry = text(b"RegistryName"); + let profile = oi.get::>(b"DestOutputProfile"); + Intent { + obj: oi.obj_id().and_then(|id| number(id.obj_number)), + name: text(b"OutputCondition").or_else(|| identifier.clone()).unwrap_or_else(|| "?".into()), + registered: registry.is_some_and(|r| r.trim_end_matches('/') == ICC_REGISTRY) && identifier.as_deref().is_some_and(|i| i != "Custom"), + has_profile: oi.contains_key(b"DestOutputProfile"), + profile_obj: oi.get_ref(b"DestOutputProfile").and_then(|r| number(r.obj_number)), + channels: profile.as_ref().and_then(|p| p.dict().get::(b"N")), + profile: profile.and_then(|p| p.decoded().ok()).map(|b| b.into_owned()), + } + }) + .collect() +} + +fn raw_pdfx_intents<'a>(file: &'a craft_pdf::syntax::Pdf) -> Vec> { + let xref = file.xref(); + let Some(catalog) = xref.get::>(xref.root_id()) else { return Vec::new() }; + let Some(intents) = catalog.get::>(b"OutputIntents") else { return Vec::new() }; + intents.iter::>().filter(|oi| oi.get::>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")).collect() +} + +fn info_text(pdf: &[u8], file: &craft_pdf::syntax::Pdf, key: &[u8]) -> Option { + let layout = Layout::read(pdf)?; + let at = layout.trailer.find("/Info")? + "/Info".len(); + let tail = layout.trailer.get(at..)?.trim_start(); + let (number, _) = tail.split_once(' ')?; + let id = number.parse::().ok()?; + let dict = file.xref().get::>(craft_pdf::syntax::object::ObjectIdentifier { obj_number: id, gen_number: 0 })?; + dict.get::>(key).map(|s| String::from_utf8_lossy(s.as_bytes()).trim().to_string()) +} + +/// What PDF/X-1a finds wrong with the file's output intent: a CMYK profile, ICC v2 (PDF 1.4 +/// allows no later version), or none for a registered printing condition. +fn intent_issues(file: &craft_pdf::syntax::Pdf) -> Vec { + let Some(oi) = pdfx_intents(file).into_iter().next() else { return Vec::new() }; + let mut issues = Vec::new(); + if oi.has_profile { + if oi.channels != Some(4) { + issues.push("the output intent has no CMYK destination profile".into()); + } + if oi.major() != Some(2) { + issues.push(format!("the output intent's profile is {}, and PDF/X-1a (PDF 1.4) allows only ICC v2 profiles", oi.version())); + } + } else if !oi.registered { + issues + .push(format!("the output intent has no destination profile and doesn't name a printing condition in the ICC registry ({ICC_REGISTRY})")); + } + issues +} + +/// krilla's PDF 1.4 file with what PDF/X-1a needs and krilla doesn't write, rewritten as one +/// revision: `GTS_PDFXConformance` in the document information and the XMP, and the output intent +/// of a registered printing condition without `/DestOutputProfile` (its profile is left out of +/// the file). An unregistered condition keeps its profile, which must then be ICC v2. +pub fn finish_x1a(pdf: &[u8]) -> Result, String> { + let unreadable = || "the flattened file couldn't be read to add the PDF/X-1a identification".to_string(); + let layout = Layout::read(pdf).ok_or_else(unreadable)?; + let file = craft_pdf::syntax::Pdf::new(pdf.to_vec()).map_err(|_| unreadable())?; + let mut bodies: HashMap> = HashMap::new(); + let info = int_after(&layout.trailer, "/Info").ok_or_else(unreadable)?; + let body = layout.body(pdf, info).ok_or_else(unreadable)?; + let (head, close) = body.split_at_checked(rfind(body, b">>").ok_or_else(unreadable)?).ok_or_else(unreadable)?; + bodies.insert(info, [head, format!("/GTS_PDFXConformance({CONFORMANCE})").as_bytes(), close].concat()); + let xref = file.xref(); + if let Some(meta) = xref.get::>(xref.root_id()).and_then(|c| c.get_ref(b"Metadata")).and_then(|r| usize::try_from(r.obj_number).ok()) + && let Some(xmp) = layout.body(pdf, meta).and_then(xmp_with_conformance) + { + bodies.insert(meta, xmp); + } + let mut stripped: Vec = Vec::new(); + for oi in pdfx_intents(&file) { + if !oi.has_profile { + continue; + } + if !oi.registered { + if oi.major() != Some(2) { + return Err(format!( + "the output intent's profile `{}` is {}, and PDF/X-1a (PDF 1.4) allows only ICC v2 profiles: choose a CMYK working space with an ICC v2 profile, or Coated FOGRA39", + oi.name, + oi.version() + )); + } + continue; + } + let (Some(obj), Some(profile)) = (oi.obj, oi.profile_obj) else { return Err(unreadable()) }; + let body = match bodies.get(&obj) { + Some(b) => b.clone(), + None => layout.body(pdf, obj).ok_or_else(unreadable)?.to_vec(), + }; + let key = format!("/DestOutputProfile {profile} 0 R"); + let at = find(&body, key.as_bytes(), 0).ok_or_else(unreadable)?; + let (before, rest) = body.split_at_checked(at).ok_or_else(unreadable)?; + let after = rest.get(key.len()..).ok_or_else(unreadable)?; + bodies.insert(obj, [before, after].concat()); + stripped.push(profile); + } + // A profile only the stripped intents named goes too. + let dropped: HashSet = stripped.iter().copied().filter(|p| references(pdf, *p) == stripped.iter().filter(|q| *q == p).count()).collect(); + layout.write(pdf, &bodies, &dropped).ok_or_else(unreadable) +} + +/// How often `n 0 R` appears in `pdf` (a stream that happens to hold those bytes counts too, which +/// only ever keeps an object). +fn references(pdf: &[u8], n: usize) -> usize { + let needle = format!("{n} 0 R"); + let mut count = 0; + let mut from = 0; + while let Some(at) = find(pdf, needle.as_bytes(), from) { + let before = at.checked_sub(1).and_then(|i| pdf.get(i)).is_none_or(|c| !c.is_ascii_digit()); + let after = pdf.get(at + needle.len()).is_none_or(|c| !c.is_ascii_alphanumeric()); + if before && after { + count += 1; + } + from = at + needle.len(); + } + count +} + +/// The body of an uncompressed XMP metadata stream object with the Adobe `pdfx` properties of +/// the identification (`GTS_PDFXVersion`, `GTS_PDFXConformance`) added and its `/Length` updated; +/// `None` when the stream is filtered or isn't XMP. +fn xmp_with_conformance(body: &[u8]) -> Option> { + let s0 = find(body, b"stream", 0)?; + let dict = String::from_utf8_lossy(body.get(..s0)?).to_string(); + let s0 = s0 + b"stream".len(); + let s0 = s0 + body.get(s0..)?.iter().take_while(|c| **c == b'\r' || **c == b'\n').count(); + let s1 = rfind(body, b"endstream")?; + let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string(); + if dict.contains("/Filter") || !xml.contains("") { + return None; + } + let ids = format!( + "{VERSION}\ + {CONFORMANCE}" + ); + let xml = xml.replacen("", &format!("{ids}"), 1); + let i = dict.find("/Length")?; + let rest = dict.get(i + 7..)?.trim_start(); + let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + let dict = format!("{}/Length {}{}", dict.get(..i)?, xml.len(), rest.get(end..)?); + Some(format!("\n{}\nstream\n{xml}\nendstream\n", dict.trim()).into_bytes()) +} + +fn find(bytes: &[u8], needle: &[u8], from: usize) -> Option { + (from <= bytes.len()).then(|| bytes.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|at| at + from))? +} + +fn rfind(bytes: &[u8], needle: &[u8]) -> Option { + bytes.windows(needle.len()).rposition(|w| w == needle) +} + +fn int_after(text: &str, key: &str) -> Option { + let at = text.find(key)? + key.len(); + text.get(at..)?.trim_start().split(|c: char| !c.is_ascii_digit()).next()?.parse().ok() +} + +/// Where the objects of a single-revision file with a classic cross-reference table are. +struct Layout { + /// (object number, start, end), by start: from `n 0 obj` up to the next object or the table. + objects: Vec<(usize, usize, usize)>, + /// Where the cross-reference table starts. + table: usize, + /// The trailer dictionary. + trailer: String, + size: usize, +} + +impl Layout { + fn read(pdf: &[u8]) -> Option { + let sx = rfind(pdf, b"startxref")?; + let table: usize = std::str::from_utf8(pdf.get(sx + b"startxref".len()..)?).ok()?.split_whitespace().next()?.parse().ok()?; + let section = pdf.get(table..sx)?; + let t = find(section, b"trailer", 0)?; + let trailer = String::from_utf8_lossy(section.get(t + b"trailer".len()..)?).trim().to_string(); + if trailer.contains("/Prev") || trailer.contains("/XRefStm") { + return None; + } + let size = int_after(&trailer, "/Size")?; + let mut tokens = std::str::from_utf8(section.get(..t)?).ok()?.split_whitespace(); + if tokens.next()? != "xref" { + return None; + } + let mut starts: Vec<(usize, usize)> = Vec::new(); + let mut seen = HashSet::new(); + while let Some(first) = tokens.next() { + let first: usize = first.parse().ok()?; + let count: usize = tokens.next()?.parse().ok()?; + for i in 0..count { + let (offset, generation, kind) = (tokens.next()?, tokens.next()?, tokens.next()?); + if kind != "n" { + continue; + } + let n = first.checked_add(i)?; + let offset: usize = offset.parse().ok()?; + if generation != "00000" || offset >= table || n >= size || !seen.insert(n) { + return None; + } + starts.push((n, offset)); + } + } + // Free entries past the objects would only make the rewritten table longer. + let max = starts.iter().map(|(n, _)| *n).max().unwrap_or(0); + if size > max.saturating_mul(2).saturating_add(16) { + return None; + } + starts.sort_by_key(|(_, offset)| *offset); + let mut objects = Vec::with_capacity(starts.len()); + for (i, &(n, start)) in starts.iter().enumerate() { + let end = starts.get(i + 1).map_or(table, |(_, s)| *s); + if !pdf.get(start..end)?.starts_with(format!("{n} 0 obj").as_bytes()) { + return None; + } + objects.push((n, start, end)); + } + Some(Layout { objects, table, trailer, size }) + } + + /// Object `n` between `n 0 obj` and `endobj`. + fn body<'p>(&self, pdf: &'p [u8], n: usize) -> Option<&'p [u8]> { + let &(_, start, end) = self.objects.iter().find(|(m, _, _)| *m == n)?; + let obj = pdf.get(start..end)?; + obj.get(format!("{n} 0 obj").len()..rfind(obj, b"endobj")?) + } + + /// The file with the objects in `bodies` replaced and those in `dropped` left out, and a new + /// cross-reference table. + fn write(&self, pdf: &[u8], bodies: &HashMap>, dropped: &HashSet) -> Option> { + let first = self.objects.first().map_or(self.table, |o| o.1); + let mut out = pdf.get(..first)?.to_vec(); + let mut offsets = HashMap::new(); + for &(n, start, end) in &self.objects { + if dropped.contains(&n) { + continue; + } + offsets.insert(n, out.len()); + match bodies.get(&n) { + Some(body) => { + out.extend_from_slice(format!("{n} 0 obj").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"endobj\n"); + } + None => out.extend_from_slice(pdf.get(start..end)?), + } + } + if !out.ends_with(b"\n") { + out.push(b'\n'); + } + let table = out.len(); + // The free entries, each pointing at the next. + let free: Vec = (1..self.size).filter(|n| !offsets.contains_key(n)).collect(); + let next: HashMap = free.iter().copied().zip(free.iter().skip(1).copied().chain([0])).collect(); + out.extend_from_slice(format!("xref\n0 {}\n{:010} 65535 f\r\n", self.size, free.first().copied().unwrap_or(0)).as_bytes()); + for n in 1..self.size { + let next = next.get(&n).copied().unwrap_or(0); + let entry = match offsets.get(&n) { + Some(offset) => format!("{offset:010} 00000 n\r\n"), + // A dropped object's number could be used again, at the next generation. + None if dropped.contains(&n) => format!("{next:010} 00001 f\r\n"), + None => format!("{next:010} 65535 f\r\n"), + }; + out.extend_from_slice(entry.as_bytes()); + } + out.extend_from_slice(format!("trailer\n{}\nstartxref\n{table}\n%%EOF\n", self.trailer).as_bytes()); + Some(out) + } +} + +/// Why a color space isn't allowed in PDF/X-1a, or `None` when it is. +fn disallowed(cs: &Object<'_>, res: &Resources<'_>, depth: usize) -> Option { + if depth > 4 { + return Some("a color space nested too deeply".into()); + } + match cs { + Object::Name(n) => match n.as_ref() { + b"DeviceCMYK" | b"CMYK" | b"DeviceGray" | b"G" | b"Pattern" => None, + b"DeviceRGB" | b"RGB" => Some("DeviceRGB color".into()), + b"Indexed" | b"I" => None, + other => match res.get_color_space(&Name::new_unescaped(other)) { + Some(named) if !matches!(named, Object::Name(_)) => disallowed(&named, res, depth + 1), + Some(Object::Name(m)) if m.as_ref() != other => disallowed(&Object::Name(m), res, depth + 1), + _ => Some(format!("the color space /{}", String::from_utf8_lossy(other))), + }, + }, + Object::Array(a) => { + let mut items = a.iter::>(); + let family = match items.next() { + Some(Object::Name(n)) => n, + _ => return Some("an unreadable color space".into()), + }; + match family.as_ref() { + b"ICCBased" => Some("ICC-based color".into()), + b"CalRGB" | b"CalGray" => Some("CIE-based (CalRGB/CalGray) color".into()), + b"Lab" => Some("Lab color".into()), + b"DeviceRGB" | b"RGB" => Some("DeviceRGB color".into()), + b"DeviceCMYK" | b"CMYK" | b"DeviceGray" | b"G" => None, + b"Indexed" | b"I" => items.next().and_then(|base| disallowed(&base, res, depth + 1)), + b"Pattern" => items.next().and_then(|base| disallowed(&base, res, depth + 1)), + b"Separation" | b"DeviceN" => { + let _names = items.next(); + let alt = items.next()?; + if let Some(why) = disallowed(&alt, res, depth + 1) { + return Some(format!("a spot color whose alternate space is {why}")); + } + let _tint = items.next(); + // NChannel attributes are PDF 1.6, past the 1.4 that PDF/X-1a:2003 allows. + let attrs = items.next().and_then(|o| match o { + Object::Dict(d) => Some(d), + _ => None, + }); + attrs + .filter(|a| a.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"NChannel") || a.contains_key(b"Process")) + .map(|_| "a DeviceN space with NChannel attributes (PDF 1.6)".into()) + } + other => Some(format!("the color space /{}", String::from_utf8_lossy(other))), + } + } + _ => Some("an unreadable color space".into()), + } +} + +/// What one page has that PDF/X-1a doesn't allow, in the order found. +#[derive(Default)] +struct Scan { + /// What was found, in the order found; `known` is the same as a set. + found: Vec, + known: HashSet, + font_issues: Vec, + seen: HashSet, + fonts: HashSet, +} + +impl Scan { + fn add(&mut self, what: String) { + if self.known.insert(what.clone()) { + self.found.push(what); + } + } + + fn space(&mut self, cs: &Object<'_>, res: &Resources<'_>) { + if let Some(why) = disallowed(cs, res, 0) { + self.add(why); + } + } + + fn content(&mut self, data: &[u8], res: &Resources<'_>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let mut ops = UntypedIter::new(data); + while let Some(op) = ops.next() { + let operator: &[u8] = op.operator; + let last = op.operands().last().cloned(); + let name = || last.clone().and_then(Object::into_name); + match operator { + b"rg" | b"RG" => self.add("DeviceRGB color".into()), + b"cs" | b"CS" => { + if let Some(n) = name() { + self.space(&Object::Name(n), res); + } + } + b"sh" => { + if let Some(sh) = name().and_then(|n| res.get_shading(&n)) { + self.shading(sh, res); + } + } + b"scn" | b"SCN" => { + if let Some(p) = name().and_then(|n| res.get_pattern(&n)) { + self.pattern(p, res, depth); + } + } + b"gs" => { + if let Some(gs) = name().and_then(|n| res.get_ext_g_state(&n)) { + self.ext_g_state(&gs, res, depth); + } + } + b"Tf" => { + let font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)); + if let Some(font) = font { + self.font(&font, res, depth); + } + } + b"Do" => { + if let Some(xo) = name().and_then(|n| res.get_x_object(&n)) { + match xo.dict().get::>(b"Subtype").as_deref() { + Some(b"Image") => self.image(&xo, res), + Some(b"Form") => self.form(&xo, res, depth), + _ => {} + } + } + } + b"BI" | b"EI" => { + if let Some(Object::Stream(img)) = last + && let Some(cs) = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")) + { + self.space(&cs, res); + } + } + _ => {} + } + } + } + + fn ext_g_state(&mut self, gs: &Dict<'_>, res: &Resources<'_>, depth: usize) { + match gs.get::>(b"SMask") { + None => {} + Some(Object::Name(n)) if n.as_ref() == b"None" => {} + Some(mask) => { + self.add("a soft mask (transparency)".into()); + if let Some(form) = match mask { + Object::Dict(d) => d.get::>(b"G"), + _ => None, + } { + self.form(&form, res, depth); + } + } + } + for key in [&b"CA"[..], b"ca"] { + if gs.get::(key).is_some_and(|a| a < 1.0) { + self.add("constant opacity below 100% (transparency)".into()); + } + } + let normal = |n: &[u8]| matches!(n, b"Normal" | b"Compatible"); + match gs.get::>(b"BM") { + Some(Object::Name(n)) if !normal(n.as_ref()) => { + self.add(format!("the blend mode /{} (transparency)", String::from_utf8_lossy(n.as_ref()))) + } + Some(Object::Array(a)) if a.iter::>().any(|n| !normal(n.as_ref())) => { + self.add("a blend mode other than Normal (transparency)".into()) + } + _ => {} + } + } + + fn image(&mut self, img: &Stream<'_>, res: &Resources<'_>) { + if !self.seen.insert(img.obj_id()) { + return; + } + let d = img.dict(); + if d.get::>(b"SMask").is_some() { + self.add(format!("an image with a soft mask (object {}, transparency)", img.obj_id().obj_number)); + } + if d.get::(b"SMaskInData").is_some_and(|v| v != 0) { + self.add(format!("an image with transparency in its data (object {})", img.obj_id().obj_number)); + } + if d.get::(b"ImageMask") == Some(true) { + return; + } + match d.get::>(b"ColorSpace") { + Some(cs) => { + if let Some(why) = disallowed(&cs, res, 0) { + let (w, h) = (d.get::(b"Width").unwrap_or(0), d.get::(b"Height").unwrap_or(0)); + self.add(format!("{why} in an image (object {}, {w} × {h} px)", img.obj_id().obj_number)); + } + } + // JPX images may carry their own space; X-1a doesn't allow them. + None => self.add(format!("an image without a color space (object {})", img.obj_id().obj_number)), + } + } + + fn form(&mut self, form: &Stream<'_>, parent: &Resources<'_>, depth: usize) { + if depth >= MAX_DEPTH || !self.seen.insert(form.obj_id()) { + return; + } + let res = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), parent.clone()); + if let Some(group) = form.dict().get::>(b"Group") { + self.group(&group); + } + if let Ok(data) = form.decoded() { + self.content(&data, &res, depth + 1); + } + } + + fn group(&mut self, group: &Dict<'_>) { + if group.get::>(b"S").is_some_and(|s| s.as_ref() == b"Transparency") { + self.add("a transparency group".into()); + } + } + + fn shading(&mut self, sh: Object<'_>, res: &Resources<'_>) { + let dict = match sh { + Object::Dict(d) => d, + Object::Stream(s) => s.dict().clone(), + _ => return, + }; + if let Some(cs) = dict.get::>(b"ColorSpace") { + self.space(&cs, res); + } + } + + fn pattern(&mut self, p: Object<'_>, res: &Resources<'_>, depth: usize) { + match p { + Object::Dict(d) => { + if let Some(sh) = d.get::>(b"Shading") { + self.shading(sh, res); + } + if let Some(gs) = d.get::>(b"ExtGState") { + self.ext_g_state(&gs, res, depth); + } + } + Object::Stream(s) => self.form(&s, res, depth), + _ => {} + } + } + + /// Embedded: a simple font's descriptor, or a Type 0 font's descendant's, has a font file. A + /// Type 3 font's glyphs are content streams in the file, scanned like the page's. + fn font(&mut self, font: &Dict<'_>, parent: &Resources<'_>, depth: usize) { + if let Some(id) = font.obj_id() + && !self.fonts.insert(id) + { + return; + } + let name = font.get::>(b"BaseFont").map(|n| String::from_utf8_lossy(n.as_ref()).to_string()).unwrap_or_else(|| "?".into()); + let subtype = font.get::>(b"Subtype"); + let descriptor = match subtype.as_deref() { + Some(b"Type3") => return self.type3(font, parent, depth), + Some(b"Type0") => { + font.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).and_then(|d| d.get::>(b"FontDescriptor")) + } + _ => font.get::>(b"FontDescriptor"), + }; + let embedded = descriptor.is_some_and(|d| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| d.get::>(k).is_some())); + if !embedded { + self.font_issues.push(format!("the font {name} isn't embedded")); + } + } + + /// A Type 3 font's glyph procedures, with the font's resources over `parent`'s. + fn type3(&mut self, font: &Dict<'_>, parent: &Resources<'_>, depth: usize) { + if depth >= MAX_DEPTH { + return; + } + let res = Resources::from_parent(font.get::>(b"Resources").unwrap_or_default(), parent.clone()); + let Some(procs) = font.get::>(b"CharProcs") else { return }; + for key in procs.keys() { + if let Some(glyph) = procs.get::>(key.as_ref()) + && self.seen.insert(glyph.obj_id()) + && let Ok(data) = glyph.decoded() + { + self.content(&data, &res, depth + 1); + } + } + } +} diff --git a/crates/prepress/tests/flatten_document.rs b/crates/prepress/tests/flatten_document.rs index 165a80e9a..a24f5dfea 100644 --- a/crates/prepress/tests/flatten_document.rs +++ b/crates/prepress/tests/flatten_document.rs @@ -116,7 +116,6 @@ fn three_pages(font_bytes: &[u8]) -> Vec { s.pop(); text(s, &font, &face, (20.0, 110.0), 12.0, "World"); }); - drop(page); writer.finish().unwrap() } diff --git a/crates/xml/Cargo.toml b/crates/xml/Cargo.toml index f957fa625..02d68985b 100644 --- a/crates/xml/Cargo.toml +++ b/crates/xml/Cargo.toml @@ -13,3 +13,4 @@ workspace = true [dependencies] quick-xml = { version = "0.41", default-features = false } thiserror = { workspace = true } +serde = { workspace = true, features = ["derive"] } diff --git a/crates/xml/src/dtd.rs b/crates/xml/src/dtd.rs new file mode 100644 index 000000000..6bbca3554 --- /dev/null +++ b/crates/xml/src/dtd.rs @@ -0,0 +1,748 @@ +//! DTD content-model parsing and XML validation. + +//! Document Type Definitions: element declarations (content models) and required attributes, and +//! validation of an XML document against them (Structure ▸ Validate). + +use std::collections::HashMap; + +/// How much text parameter entities may add during expansion (a pass count is not an output +/// bound: each pass replaces whole strings, so references can inflate multiplicatively). +const MAX_EXPANDED_BYTES: usize = 1024 * 1024; + +/// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`). +#[derive(Clone, Debug, PartialEq)] +enum Cp { + Name(String), + Seq(Vec), + Choice(Vec), + Rep(Box, char), +} + +#[derive(Clone, Debug, PartialEq)] +enum Model { + Empty, + Any, + /// `(#PCDATA | a | b)*`: text and these elements in any order. + Mixed(Vec), + Children(Cp), +} + +/// A parsed DTD. +#[derive(Clone, Debug, Default)] +pub struct Dtd { + elements: Vec<(String, Model)>, + /// Element → its `#REQUIRED` attributes. + required: HashMap>, +} + +/// A problem found by validation: where (an element path such as `Root/story[2]`) and what. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Problem { + pub path: String, + pub message: String, +} + +impl Dtd { + /// Declared element names, in order. + pub fn element_names(&self) -> impl Iterator { + self.elements.iter().map(|(n, _)| n.as_str()) + } + + fn model(&self, name: &str) -> Option<&Model> { + self.elements.iter().find(|(n, _)| n == name).map(|(_, m)| m) + } +} + +/// Markup declarations (``) with comments removed and quoted strings kept whole. +fn declarations(src: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = src; + while let Some(i) = rest.find("").map_or("", |j| &rest[j + 3..]); + continue; + } + let mut quote = None; + let mut end = rest.len(); + for (k, c) in rest.char_indices().skip(2) { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (None, '"' | '\'') => quote = Some(c), + (None, '>') => { + end = k; + break; + } + _ => {} + } + } + out.push(rest[2..end].to_string()); + rest = rest.get(end + 1..).unwrap_or(""); + } + out +} + +fn tokens(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + for c in s.chars() { + if c.is_whitespace() || "(),|?*+".contains(c) { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + if !c.is_whitespace() { + out.push(c.to_string()); + } + } else { + cur.push(c); + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// How deeply groups may nest in a content model; parsing, matching (`ends`) and printing (`show`) +/// recurse once per level. +const MAX_CONTENT_MODEL_DEPTH: usize = 64; + +/// Parse a content particle from `t[*i..]`, inside `depth` enclosing groups. +fn particle(t: &[String], i: &mut usize, depth: usize) -> Result { + let base = match t.get(*i).map(String::as_str) { + Some("(") => { + if depth >= MAX_CONTENT_MODEL_DEPTH { + return Err(format!("content model nested more than {MAX_CONTENT_MODEL_DEPTH} levels")); + } + *i += 1; + let mut items = vec![particle(t, i, depth + 1)?]; + let mut sep = None; + loop { + match t.get(*i).map(String::as_str) { + Some(")") => { + *i += 1; + break; + } + Some(s @ ("," | "|")) => { + if sep.is_some_and(|p| p != s) { + return Err("mixed `,` and `|` in one group".into()); + } + sep = Some(if s == "," { "," } else { "|" }); + *i += 1; + items.push(particle(t, i, depth + 1)?); + } + other => return Err(format!("unexpected `{}` in a content model", other.unwrap_or("end"))), + } + } + if sep == Some("|") { Cp::Choice(items) } else { Cp::Seq(items) } + } + Some(n) if !"),|?*+".contains(n) => { + *i += 1; + Cp::Name(n.to_string()) + } + other => return Err(format!("unexpected `{}` in a content model", other.unwrap_or("end"))), + }; + Ok(match t.get(*i).map(String::as_str) { + Some(r @ ("?" | "*" | "+")) => { + *i += 1; + Cp::Rep(Box::new(base), r.chars().next().unwrap_or('*')) + } + _ => base, + }) +} + +fn parse_model(s: &str) -> Result { + let s = s.trim(); + match s { + "EMPTY" => return Ok(Model::Empty), + "ANY" => return Ok(Model::Any), + _ => {} + } + if s.contains("#PCDATA") { + let t = tokens(&s.replace("#PCDATA", " #PCDATA ")); + return Ok(Model::Mixed(t.into_iter().filter(|x| !"()|*".contains(x.as_str()) && x != "#PCDATA").collect())); + } + let t = tokens(s); + let mut i = 0; + let cp = particle(&t, &mut i, 0)?; + if i != t.len() { + return Err(format!("unexpected `{}` after the content model", t[i])); + } + Ok(Model::Children(cp)) +} + +/// How deeply parameter entities may nest: a reference in the DTD is level 1, a reference in its +/// value level 2, and so on. +const MAX_PARAMETER_ENTITY_DEPTH: usize = 64; + +/// Expand `%name;` references, one level per round, until a round changes nothing. +fn expand_parameter_entities(src: &str, ents: &[(String, String)]) -> Result { + let mut entities = HashMap::new(); + for (name, value) in ents { + entities.entry(name.as_str()).or_insert(value.as_str()); + } + let limit = src.len().saturating_add(MAX_EXPANDED_BYTES); + let append = |out: &mut String, value: &str| -> Result<(), String> { + if value.len() > limit.saturating_sub(out.len()) { + return Err(format!("parameter entities expand the DTD beyond {limit} bytes")); + } + out.push_str(value); + Ok(()) + }; + let too_deep = || format!("parameter entities refer to themselves or nest deeper than {MAX_PARAMETER_ENTITY_DEPTH} levels"); + let mut text = src.to_string(); + // The round after the deepest level changes nothing, hence one more round than levels. + for _ in 0..=MAX_PARAMETER_ENTITY_DEPTH { + let mut out = String::with_capacity(text.len()); + let mut rest = text.as_str(); + let mut changed = false; + while let Some(i) = rest.find('%') { + let (prefix, reference) = rest.split_at(i); + append(&mut out, prefix)?; + // `find` points to an ASCII `%`, so both split positions are UTF-8 boundaries. + rest = reference.split_at(1).1; + // A name has no whitespace and no `%` (`parse` ends a name at whitespace), whatever its + // length: the search for the closing `;` stops at the first of those, so the searches of + // a round never overlap and a round stays linear in the text. + let end = + rest.bytes().position(|b| matches!(b, b';' | b'%') || b.is_ascii_whitespace()).filter(|&k| rest.as_bytes().get(k) == Some(&b';')); + if let Some(end) = end + && let Some(value) = rest.get(..end).and_then(|name| entities.get(name)) + { + // A value that is its own reference (``) never gets anywhere. + if reference.get(..end + 2) == Some(*value) { + return Err(too_deep()); + } + append(&mut out, value)?; + changed = true; + rest = rest.split_at(end + 1).1; + continue; + } + append(&mut out, "%")?; + } + append(&mut out, rest)?; + text = out; + if !changed { + return Ok(text); + } + } + Err(too_deep()) +} + +/// Parse a DTD (its element and attribute-list declarations; parameter entities are expanded). +pub fn parse(src: &str) -> Result { + // Parameter entities first, then expand references and read the declarations again. + let mut ents: Vec<(String, String)> = Vec::new(); + for d in declarations(src) { + if let Some(rest) = d.strip_prefix("ENTITY") { + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix('%') { + let rest = rest.trim_start(); + let name: String = rest.chars().take_while(|c| !c.is_whitespace()).collect(); + let val = rest[name.len()..].trim(); + let q = val.chars().next().unwrap_or('"'); + if let Some(v) = val.strip_prefix(q).and_then(|v| v.find(q).map(|e| &v[..e])) { + ents.push((name, v.to_string())); + } + } + } + } + let text = expand_parameter_entities(src, &ents)?; + let mut dtd = Dtd::default(); + for d in declarations(&text) { + if let Some(rest) = d.strip_prefix("ELEMENT") { + let rest = rest.trim_start(); + let name: String = rest.chars().take_while(|c| !c.is_whitespace()).collect(); + let model = parse_model(&rest[name.len()..]).map_err(|e| format!(": {e}"))?; + dtd.elements.push((name, model)); + } else if let Some(rest) = d.strip_prefix("ATTLIST") { + // name (attr type default)*; only `#REQUIRED` matters here. + let mut words = Vec::new(); + let mut cur = String::new(); + let mut quote = None; + let mut group = 0; + for c in rest.chars() { + match (quote, c) { + (Some(q), c) if c == q => { + quote = None; + cur.push(c); + } + (Some(_), c) => cur.push(c), + (None, '"' | '\'') => { + quote = Some(c); + cur.push(c); + } + (None, '(') => { + group += 1; + cur.push(c); + } + (None, ')') => { + group -= 1; + cur.push(c); + } + (None, c) if c.is_whitespace() && group == 0 => { + if !cur.is_empty() { + words.push(std::mem::take(&mut cur)); + } + } + (None, c) => cur.push(c), + } + } + if !cur.is_empty() { + words.push(cur); + } + let Some((el, attrs)) = words.split_first() else { continue }; + let mut k = 0; + while k + 2 < attrs.len() { + let name = &attrs[k]; + let default = attrs.get(k + 2).map(String::as_str).unwrap_or(""); + if default == "#REQUIRED" { + dtd.required.entry(el.clone()).or_default().push(name.clone()); + } + k += if default == "#FIXED" { 4 } else { 3 }; + } + } + } + if dtd.elements.is_empty() { + return Err("no element declarations".into()); + } + Ok(dtd) +} + +/// What `ends` has worked out for one sequence of children: the end positions of a group (by the +/// address of its node, which lives as long as the model) from a position. +type EndsMemo = HashMap<(usize, usize), Vec>; + +/// End positions after `cp` matches a prefix of `seq[i..]`. +/// +/// The positions of each group are worked out once per start position (`memo`): nested repetitions +/// such as `((a*)*)*` ask the inner group for the same positions again and again, which is +/// exponential in their depth without it. +fn ends(cp: &Cp, seq: &[&str], i: usize, memo: &mut EndsMemo) -> Vec { + if let Cp::Name(n) = cp { + return if seq.get(i) == Some(&n.as_str()) { vec![i + 1] } else { vec![] }; + } + let key = (std::ptr::from_ref(cp) as usize, i); + if let Some(done) = memo.get(&key) { + return done.clone(); + } + let mut out: Vec = match cp { + Cp::Name(_) => vec![], + // Keep the positions after each item a set: as a list of every path, `(a?, a?, …)` doubles per item. + Cp::Seq(items) => { + let mut at = vec![i]; + for c in items { + let mut next = Vec::new(); + for p in at { + next.extend(ends(c, seq, p, memo)); + } + next.sort_unstable(); + next.dedup(); + at = next; + } + at + } + Cp::Choice(items) => { + let mut all = Vec::new(); + for c in items { + all.extend(ends(c, seq, i, memo)); + } + all + } + Cp::Rep(c, r) => { + let mut all = if *r == '+' { vec![] } else { vec![i] }; + let mut frontier = ends(c, seq, i, memo); + if *r == '?' { + all.extend(frontier); + } else { + while !frontier.is_empty() { + let new: Vec = frontier.iter().copied().filter(|p| !all.contains(p)).collect(); + all.extend(&new); + let mut next = Vec::new(); + for p in new.into_iter().filter(|&p| p > i) { + next.extend(ends(c, seq, p, memo).into_iter().filter(|p| !all.contains(p))); + } + // Two paths to one position are one position: kept apart they multiply per round. + next.sort_unstable(); + next.dedup(); + frontier = next; + } + } + all + } + }; + out.sort_unstable(); + out.dedup(); + memo.insert(key, out.clone()); + out +} + +fn show(cp: &Cp) -> String { + match cp { + Cp::Name(n) => n.clone(), + Cp::Seq(v) => format!("({})", v.iter().map(show).collect::>().join(", ")), + Cp::Choice(v) => format!("({})", v.iter().map(show).collect::>().join(" | ")), + Cp::Rep(c, r) => format!("{}{r}", show(c)), + } +} + +/// An element of the document being validated. +struct Node { + name: String, + attrs: Vec, + children: Vec, + text: bool, +} + +fn read_tree(xml: &str) -> Result { + use quick_xml::events::Event; + let mut r = quick_xml::Reader::from_str(xml); + let mut stack: Vec = Vec::new(); + let node = |e: &quick_xml::events::BytesStart| Node { + name: String::from_utf8_lossy(e.name().as_ref()).to_string(), + attrs: e.attributes().flatten().map(|a| String::from_utf8_lossy(a.key.as_ref()).to_string()).collect(), + children: vec![], + text: false, + }; + loop { + match r.read_event().map_err(|e| e.to_string())? { + Event::Start(e) => stack.push(node(&e)), + Event::Empty(e) => { + let n = node(&e); + match stack.last_mut() { + Some(p) => p.children.push(n), + None => return Ok(n), + } + } + Event::End(_) => { + let n = stack.pop().ok_or("unbalanced end tag")?; + match stack.last_mut() { + Some(p) => p.children.push(n), + None => return Ok(n), + } + } + Event::Text(t) => { + if let Some(p) = stack.last_mut() + && !t.iter().all(u8::is_ascii_whitespace) + { + p.text = true; + } + } + Event::CData(_) | Event::GeneralRef(_) => { + if let Some(p) = stack.last_mut() { + p.text = true; + } + } + Event::Eof => return Err("no root element".into()), + _ => {} + } + } +} + +fn check(dtd: &Dtd, n: &Node, path: &str, out: &mut Vec) { + let mut problem = |m: String| out.push(Problem { path: path.to_string(), message: m }); + match dtd.model(&n.name) { + None => problem(format!("`{}` isn't declared in the DTD", n.name)), + Some(Model::Any) => {} + Some(Model::Empty) => { + if n.text || !n.children.is_empty() { + problem(format!("`{}` should be empty", n.name)); + } + } + Some(Model::Mixed(allowed)) => { + for c in &n.children { + if !allowed.contains(&c.name) { + problem(format!("`{}` isn't allowed in `{}`", c.name, n.name)); + } + } + } + Some(Model::Children(cp)) => { + if n.text { + problem(format!("`{}` can't contain text", n.name)); + } + let seq: Vec<&str> = n.children.iter().map(|c| c.name.as_str()).collect(); + if !ends(cp, &seq, 0, &mut EndsMemo::new()).contains(&seq.len()) { + let got = if seq.is_empty() { "nothing".to_string() } else { seq.join(", ") }; + problem(format!("`{}` contains {got}; the DTD asks for {}", n.name, show(cp))); + } + } + } + for a in dtd.required.get(&n.name).into_iter().flatten() { + if !n.attrs.contains(a) { + problem(format!("`{}` is missing the required attribute `{a}`", n.name)); + } + } + let mut seen: HashMap<&str, usize> = HashMap::new(); + for c in &n.children { + let k = seen.entry(c.name.as_str()).or_default(); + *k += 1; + check(dtd, c, &format!("{path}/{}[{k}]", c.name), out); + } +} + +/// Validate `xml` against `dtd`; an empty list when it's valid. +pub fn validate(dtd: &Dtd, xml: &str) -> Vec { + match read_tree(xml) { + Ok(root) => { + let mut out = Vec::new(); + let path = root.name.clone(); + check(dtd, &root, &path, &mut out); + out + } + Err(e) => vec![Problem { path: String::new(), message: format!("not well-formed XML: {e}") }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DTD: &str = r##" + + + + + + + + + + b" kind (photo|chart) "photo">"##; + + #[test] + fn content_models_and_required_attributes() { + let dtd = parse(DTD).unwrap(); + assert_eq!(dtd.element_names().count(), 8); + let ok = r#"T <em>x</em>ab
"#; + assert_eq!(validate(&dtd, ok), vec![]); + let bad = r#"no title
x