mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge pull request #1322 from storytold/codex/craft-xml-dtd
prepress: share PDF/X-1a finalization and checks (official replacement)
This commit is contained in:
1 parent
53cc3c0f1a
commit
a5ac3aa05c
11 files changed
+1777
-2
No files matched your search
Generated
+80
-1
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Self, Error> {
|
||||
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<std::borrow::Cow<'_, str>, 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<usvg::ImageKind> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<u8>, Vec<String>), 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<String> = 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<String> = 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<String> {
|
||||
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<String> {
|
||||
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<String>, Vec<String>) {
|
||||
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<String>, Vec<String>) {
|
||||
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<usize>)> = Vec::new();
|
||||
let mut font_found: Vec<(String, Vec<usize>)> = 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::<Dict<'_>>(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<usize>)| {
|
||||
let list: Vec<String> = 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<usize>,
|
||||
/// `/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<usize>,
|
||||
/// The profile's `/N` and decoded bytes.
|
||||
channels: Option<i32>,
|
||||
profile: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// The profile's ICC major version (byte 8 of its header).
|
||||
fn major(&self) -> Option<u8> {
|
||||
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<Intent> {
|
||||
let number = |n: i32| usize::try_from(n).ok();
|
||||
raw_pdfx_intents(file)
|
||||
.into_iter()
|
||||
.map(|oi| {
|
||||
let text = |key: &[u8]| {
|
||||
oi.get::<craft_pdf::syntax::object::String<'_>>(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::<Stream<'_>>(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::<i32>(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<Dict<'a>> {
|
||||
let xref = file.xref();
|
||||
let Some(catalog) = xref.get::<Dict<'_>>(xref.root_id()) else { return Vec::new() };
|
||||
let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents") else { return Vec::new() };
|
||||
intents.iter::<Dict<'_>>().filter(|oi| oi.get::<Name<'_>>(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<String> {
|
||||
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::<i32>().ok()?;
|
||||
let dict = file.xref().get::<Dict<'_>>(craft_pdf::syntax::object::ObjectIdentifier { obj_number: id, gen_number: 0 })?;
|
||||
dict.get::<craft_pdf::syntax::object::String<'_>>(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<String> {
|
||||
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<Vec<u8>, 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<usize, Vec<u8>> = 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::<Dict<'_>>(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<usize> = 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<usize> = 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<Vec<u8>> {
|
||||
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("</rdf:RDF>") {
|
||||
return None;
|
||||
}
|
||||
let ids = format!(
|
||||
"<rdf:Description rdf:about=\"\" xmlns:pdfx=\"http://ns.adobe.com/pdfx/1.3/\"><pdfx:GTS_PDFXVersion>{VERSION}</pdfx:GTS_PDFXVersion>\
|
||||
<pdfx:GTS_PDFXConformance>{CONFORMANCE}</pdfx:GTS_PDFXConformance></rdf:Description>"
|
||||
);
|
||||
let xml = xml.replacen("</rdf:RDF>", &format!("{ids}</rdf:RDF>"), 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<usize> {
|
||||
(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<usize> {
|
||||
bytes.windows(needle.len()).rposition(|w| w == needle)
|
||||
}
|
||||
|
||||
fn int_after(text: &str, key: &str) -> Option<usize> {
|
||||
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<Layout> {
|
||||
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<usize, Vec<u8>>, dropped: &HashSet<usize>) -> Option<Vec<u8>> {
|
||||
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<usize> = (1..self.size).filter(|n| !offsets.contains_key(n)).collect();
|
||||
let next: HashMap<usize, usize> = 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<String> {
|
||||
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::<Object<'_>>();
|
||||
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::<Name<'_>>(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<String>,
|
||||
known: HashSet<String>,
|
||||
font_issues: Vec<String>,
|
||||
seen: HashSet<ObjectIdentifier>,
|
||||
fonts: HashSet<ObjectIdentifier>,
|
||||
}
|
||||
|
||||
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::<Name<'_>>(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::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(b"ColorSpace"))
|
||||
{
|
||||
self.space(&cs, res);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ext_g_state(&mut self, gs: &Dict<'_>, res: &Resources<'_>, depth: usize) {
|
||||
match gs.get::<Object<'_>>(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::<Stream<'_>>(b"G"),
|
||||
_ => None,
|
||||
} {
|
||||
self.form(&form, res, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in [&b"CA"[..], b"ca"] {
|
||||
if gs.get::<f32>(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::<Object<'_>>(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::<Name<'_>>().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::<Object<'_>>(b"SMask").is_some() {
|
||||
self.add(format!("an image with a soft mask (object {}, transparency)", img.obj_id().obj_number));
|
||||
}
|
||||
if d.get::<i32>(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::<bool>(b"ImageMask") == Some(true) {
|
||||
return;
|
||||
}
|
||||
match d.get::<Object<'_>>(b"ColorSpace") {
|
||||
Some(cs) => {
|
||||
if let Some(why) = disallowed(&cs, res, 0) {
|
||||
let (w, h) = (d.get::<i32>(b"Width").unwrap_or(0), d.get::<i32>(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::<Dict<'_>>(b"Resources").unwrap_or_default(), parent.clone());
|
||||
if let Some(group) = form.dict().get::<Dict<'_>>(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::<Name<'_>>(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::<Object<'_>>(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::<Object<'_>>(b"Shading") {
|
||||
self.shading(sh, res);
|
||||
}
|
||||
if let Some(gs) = d.get::<Dict<'_>>(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::<Name<'_>>(b"BaseFont").map(|n| String::from_utf8_lossy(n.as_ref()).to_string()).unwrap_or_else(|| "?".into());
|
||||
let subtype = font.get::<Name<'_>>(b"Subtype");
|
||||
let descriptor = match subtype.as_deref() {
|
||||
Some(b"Type3") => return self.type3(font, parent, depth),
|
||||
Some(b"Type0") => {
|
||||
font.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).and_then(|d| d.get::<Dict<'_>>(b"FontDescriptor"))
|
||||
}
|
||||
_ => font.get::<Dict<'_>>(b"FontDescriptor"),
|
||||
};
|
||||
let embedded = descriptor.is_some_and(|d| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| d.get::<Stream<'_>>(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::<Dict<'_>>(b"Resources").unwrap_or_default(), parent.clone());
|
||||
let Some(procs) = font.get::<Dict<'_>>(b"CharProcs") else { return };
|
||||
for key in procs.keys() {
|
||||
if let Some(glyph) = procs.get::<Stream<'_>>(key.as_ref())
|
||||
&& self.seen.insert(glyph.obj_id())
|
||||
&& let Ok(data) = glyph.decoded()
|
||||
{
|
||||
self.content(&data, &res, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,6 @@ fn three_pages(font_bytes: &[u8]) -> Vec<u8> {
|
||||
s.pop();
|
||||
text(s, &font, &face, (20.0, 110.0), 12.0, "World");
|
||||
});
|
||||
drop(page);
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@ workspace = true
|
||||
[dependencies]
|
||||
quick-xml = { version = "0.41", default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -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<Cp>),
|
||||
Choice(Vec<Cp>),
|
||||
Rep(Box<Cp>, char),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Model {
|
||||
Empty,
|
||||
Any,
|
||||
/// `(#PCDATA | a | b)*`: text and these elements in any order.
|
||||
Mixed(Vec<String>),
|
||||
Children(Cp),
|
||||
}
|
||||
|
||||
/// A parsed DTD.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dtd {
|
||||
elements: Vec<(String, Model)>,
|
||||
/// Element → its `#REQUIRED` attributes.
|
||||
required: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<Item = &str> {
|
||||
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<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = src;
|
||||
while let Some(i) = rest.find("<!") {
|
||||
rest = &rest[i..];
|
||||
if rest.starts_with("<!--") {
|
||||
rest = 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<String> {
|
||||
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<Cp, String> {
|
||||
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<Model, String> {
|
||||
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<String, String> {
|
||||
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 (`<!ENTITY % a "%a;">`) 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<Dtd, String> {
|
||||
// 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!("<!ELEMENT {name}>: {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<usize>>;
|
||||
|
||||
/// 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<usize> {
|
||||
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<usize> = 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<usize> = 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::<Vec<_>>().join(", ")),
|
||||
Cp::Choice(v) => format!("({})", v.iter().map(show).collect::<Vec<_>>().join(" | ")),
|
||||
Cp::Rep(c, r) => format!("{}{r}", show(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An element of the document being validated.
|
||||
struct Node {
|
||||
name: String,
|
||||
attrs: Vec<String>,
|
||||
children: Vec<Node>,
|
||||
text: bool,
|
||||
}
|
||||
|
||||
fn read_tree(xml: &str) -> Result<Node, String> {
|
||||
use quick_xml::events::Event;
|
||||
let mut r = quick_xml::Reader::from_str(xml);
|
||||
let mut stack: Vec<Node> = 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<Problem>) {
|
||||
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<Problem> {
|
||||
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##"<!-- an article -->
|
||||
<!ENTITY % inline "#PCDATA | em | term">
|
||||
<!ELEMENT Root (story+, figure*)>
|
||||
<!ELEMENT story (title, (para | list)*)>
|
||||
<!ELEMENT title (%inline;)*>
|
||||
<!ELEMENT para (%inline;)*>
|
||||
<!ELEMENT list (para+)>
|
||||
<!ELEMENT em (#PCDATA)>
|
||||
<!ELEMENT term (#PCDATA)>
|
||||
<!ELEMENT figure EMPTY>
|
||||
<!ATTLIST figure href CDATA #REQUIRED alt CDATA "a > 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#"<Root><story><title>T <em>x</em></title><para>a</para><list><para>b</para></list></story><figure href="a.png"/></Root>"#;
|
||||
assert_eq!(validate(&dtd, ok), vec![]);
|
||||
let bad = r#"<Root><story><para>no title</para></story><figure/><figure href="b"><em>x</em></figure><aside/></Root>"#;
|
||||
let p = validate(&dtd, bad);
|
||||
let msgs: Vec<String> = p.iter().map(|p| format!("{}: {}", p.path, p.message)).collect();
|
||||
assert!(msgs.iter().any(|m| m.starts_with("Root/story[1]: `story` contains para; the DTD asks for (title, (para | list)*)")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m == "Root/figure[1]: `figure` is missing the required attribute `href`"), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m == "Root/figure[2]: `figure` should be empty"), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m == "Root/aside[1]: `aside` isn't declared in the DTD"), "{msgs:?}");
|
||||
// The root's model fails too (aside isn't in it).
|
||||
assert!(msgs.iter().any(|m| m.starts_with("Root: ")), "{msgs:?}");
|
||||
// Text where only elements are allowed; an element not in a mixed model.
|
||||
let p = validate(&dtd, "<Root><story>loose<title><list/></title></story></Root>");
|
||||
assert!(p.iter().any(|p| p.message == "`story` can't contain text"), "{p:?}");
|
||||
assert!(p.iter().any(|p| p.message == "`list` isn't allowed in `title`"), "{p:?}");
|
||||
assert!(parse("<!ELEMENT a (b, c | d)>").is_err());
|
||||
assert!(parse("just text").is_err());
|
||||
}
|
||||
|
||||
/// A few hundred bytes of nested parameter entities used to inflate multiplicatively across
|
||||
/// the eight wholly-string-replacing passes (#713); expansion is capped.
|
||||
#[test]
|
||||
fn parameter_entities_cannot_expand_without_a_budget() {
|
||||
let mut src = String::from("<!ENTITY % e1 \"xx\">\n");
|
||||
for i in 2..=6 {
|
||||
src.push_str(&format!("<!ENTITY % e{i} \"{}\">\n", format!("%e{};", i - 1).repeat(16)));
|
||||
}
|
||||
src.push_str("<!ELEMENT r (%e6;)>");
|
||||
assert!(src.len() < 500, "the payload is small: {} bytes", src.len());
|
||||
let err = parse(&src).unwrap_err();
|
||||
assert!(err.contains("expand the DTD beyond"), "{err}");
|
||||
// Entities that expand a little still parse.
|
||||
let dtd = parse("<!ENTITY % inline \"#PCDATA | em\">\n<!ELEMENT r (%inline;)*>").unwrap();
|
||||
assert_eq!(dtd.element_names().collect::<Vec<_>>(), ["r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetition_matches_like_a_regular_expression() {
|
||||
let dtd = parse("<!ELEMENT r ((a, b?)+, c*)><!ELEMENT a EMPTY><!ELEMENT b EMPTY><!ELEMENT c EMPTY>").unwrap();
|
||||
for (xml, ok) in [
|
||||
("<r><a/></r>", true),
|
||||
("<r><a/><b/><a/><c/><c/></r>", true),
|
||||
("<r><a/><a/><b/></r>", true),
|
||||
("<r><b/></r>", false),
|
||||
("<r><a/><b/><b/></r>", false),
|
||||
("<r><a/><c/><a/></r>", false),
|
||||
("<r/>", false),
|
||||
] {
|
||||
assert_eq!(validate(&dtd, xml).is_empty(), ok, "{xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_bomb_is_rejected() {
|
||||
let mut src = String::new();
|
||||
for i in (0..10).rev() {
|
||||
let value = if i == 0 { "x".to_string() } else { format!("%e{};", i - 1).repeat(8) };
|
||||
src.push_str(&format!("<!ENTITY % e{i} \"{value}\">\n"));
|
||||
}
|
||||
src.push_str("<!ELEMENT root (%e9;)>");
|
||||
match parse(&src) {
|
||||
Err(error) => assert!(error.contains("parameter entities expand"), "{error}"),
|
||||
Ok(_) => panic!("parameter entity expansion bomb was accepted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` parameter entities, `e{i}` referring to `e{i-1}` and `e0` being `x`, declared in ascending
|
||||
/// or descending order, and an element `root` whose model uses the last one.
|
||||
fn entity_chain(n: usize, ascending: bool) -> String {
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
if !ascending {
|
||||
order.reverse();
|
||||
}
|
||||
let mut src = String::new();
|
||||
for i in order {
|
||||
let value = if i == 0 { "x".to_string() } else { format!("%e{};", i - 1) };
|
||||
src.push_str(&format!("<!ENTITY % e{i} \"{value}\">\n"));
|
||||
}
|
||||
src.push_str(&format!("<!ELEMENT root (%e{};)><!ELEMENT x EMPTY>", n - 1));
|
||||
src
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_reverse_chain_expands_completely() {
|
||||
let dtd = parse(&entity_chain(20, false)).unwrap();
|
||||
assert_eq!(validate(&dtd, "<root><x/></root>"), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_ascending_chain_expands_completely() {
|
||||
// With `e0` declared first, replacing the entities in declaration order resolves one level per pass.
|
||||
let dtd = parse(&entity_chain(20, true)).unwrap();
|
||||
assert_eq!(validate(&dtd, "<root><x/></root>"), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_cycle_is_refused() {
|
||||
// Two entities that refer to each other, and one whose value is its own reference.
|
||||
for src in ["<!ENTITY % a \"%b;\"><!ENTITY % b \"%a;\"><!ELEMENT root (%a;)>", "<!ENTITY % a \"%a;\"><!ELEMENT root (%a;)>"] {
|
||||
match parse(src) {
|
||||
Err(error) => assert!(error.contains("refer to themselves"), "{src}: {error}"),
|
||||
Ok(dtd) => panic!("a cycle was accepted ({src}): {:?}", dtd.model("root")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_chain_deeper_than_the_limit_is_refused() {
|
||||
for ascending in [true, false] {
|
||||
for n in [65, 70] {
|
||||
match parse(&entity_chain(n, ascending)) {
|
||||
Err(error) => assert!(error.contains("deeper than 64 levels"), "{n}, {ascending}: {error}"),
|
||||
Ok(dtd) => panic!("a chain of {n} ({ascending}) was accepted: {:?}", dtd.model("root")),
|
||||
}
|
||||
}
|
||||
let dtd = parse(&entity_chain(64, ascending)).unwrap();
|
||||
assert_eq!(validate(&dtd, "<root><x/></root>"), vec![], "{ascending}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_name_scan_stops_at_the_next_percent() {
|
||||
// A name never contains `%`, so the search for the closing `;` stops at the next one: the
|
||||
// searches of a run of `%` don't overlap and a round stays linear. (Looking past the next
|
||||
// `%` would expand this reference to an entity that is named `a%b`.)
|
||||
let entities = [("a%b".to_string(), "X".to_string())];
|
||||
assert_eq!(expand_parameter_entities("%a%b;", &entities), Ok("%a%b;".to_string()));
|
||||
// A run of `%` around a real reference still finds it.
|
||||
let entities = [("model".to_string(), "EMPTY".to_string())];
|
||||
assert_eq!(expand_parameter_entities("%%%model;%%", &entities), Ok("%%EMPTY%%".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_with_a_long_name_expands() {
|
||||
// A name can be any length: the search for the closing `;` has no window.
|
||||
let name = "n".repeat(300);
|
||||
let dtd = parse(&format!("<!ENTITY % {name} \"EMPTY\"><!ELEMENT root %{name};>")).unwrap();
|
||||
assert_eq!(dtd.model("root"), Some(&Model::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_in_text_without_a_reference_is_left_alone() {
|
||||
// `100% sure` (a space ends the search) and a `%` followed by 1 MiB without a `;` are text.
|
||||
let text = format!("100% sure, %{}", "a".repeat(1 << 20));
|
||||
let entities = [("a".to_string(), "X".to_string())];
|
||||
assert_eq!(expand_parameter_entities(&text, &entities), Ok(text.clone()));
|
||||
}
|
||||
|
||||
/// `a` inside `levels` groups, each repeated: `((a)*)*…`.
|
||||
fn nested_repetitions(levels: usize) -> String {
|
||||
format!("{}a{}", "(".repeat(levels), ")*".repeat(levels))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repetitions_validate_without_going_exponential() {
|
||||
// Ten levels over 100 children: each level asked the one below for the same positions again
|
||||
// and again, about 10^14 steps without the memo of `ends`.
|
||||
let dtd = parse(&format!("<!ELEMENT root {}><!ELEMENT a EMPTY><!ELEMENT b EMPTY>", nested_repetitions(10))).unwrap();
|
||||
assert_eq!(validate(&dtd, &format!("<root>{}</root>", "<a/>".repeat(100))), vec![]);
|
||||
let problems = validate(&dtd, &format!("<root>{}<b/></root>", "<a/>".repeat(100)));
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetition_of_a_sequence_of_optional_elements_validates() {
|
||||
// `(a, a?, a?, a?, a?, a?)*` over 90 children: every way to split the children among the
|
||||
// repetitions reaches the same positions, and the paths used to be kept apart in each round.
|
||||
let dtd = parse("<!ELEMENT r (a, a?, a?, a?, a?, a?)*><!ELEMENT a EMPTY><!ELEMENT b EMPTY>").unwrap();
|
||||
assert_eq!(validate(&dtd, &format!("<r>{}</r>", "<a/>".repeat(90))), vec![]);
|
||||
let problems = validate(&dtd, &format!("<r>{}<b/></r>", "<a/>".repeat(90)));
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_first_declaration_wins() {
|
||||
let dtd = parse("<!ENTITY % model \"EMPTY\"><!ENTITY % model \"ANY\"><!ELEMENT root %model;>").unwrap();
|
||||
assert_eq!(dtd.model("root"), Some(&Model::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_unknown_and_lone_percent_are_preserved() {
|
||||
let src = "%nothing; <!ELEMENT root EMPTY> %";
|
||||
let dtd = parse(src).unwrap();
|
||||
assert_eq!(dtd.model("root"), Some(&Model::Empty));
|
||||
assert_eq!(expand_parameter_entities("%nothing; %", &[]), Ok("%nothing; %".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_entity_many_flat_declarations_parse() {
|
||||
let mut src = String::new();
|
||||
for i in 0..5000 {
|
||||
src.push_str(&format!("<!ENTITY % e{i} \"x\">\n"));
|
||||
}
|
||||
src.push_str("<!ELEMENT root (%e0;, %e2499;, %e4999;)><!ELEMENT x EMPTY>");
|
||||
let dtd = parse(&src).unwrap();
|
||||
assert_eq!(validate(&dtd, "<root><x/><x/><x/></root>"), vec![]);
|
||||
}
|
||||
|
||||
/// An element `root` whose content model is `a` inside `depth` nested groups.
|
||||
fn nested_model(depth: usize) -> String {
|
||||
format!("<!ELEMENT root {}a{}><!ELEMENT a EMPTY>", "(".repeat(depth), ")".repeat(depth))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_model_nested_100000_deep_is_refused() {
|
||||
// One recursion per `(` overflowed the stack: an abort, not a panic.
|
||||
match parse(&nested_model(100_000)) {
|
||||
Err(error) => assert!(error.contains("nested more than 64 levels"), "{error}"),
|
||||
Ok(_) => panic!("a content model nested 100000 deep was accepted"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_model_nesting_limit() {
|
||||
let dtd = parse(&nested_model(64)).unwrap();
|
||||
assert_eq!(validate(&dtd, "<root><a/></root>"), vec![]);
|
||||
assert_eq!(validate(&dtd, "<root/>").len(), 1);
|
||||
match parse(&nested_model(65)) {
|
||||
Err(error) => assert_eq!(error, "<!ELEMENT root>: content model nested more than 64 levels"),
|
||||
Ok(_) => panic!("a content model nested 65 deep was accepted"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_of_40_optional_elements_validates() {
|
||||
// Keeping every path through the sequence meant 2^40 positions here.
|
||||
let dtd = parse(&format!("<!ELEMENT r ({})><!ELEMENT a EMPTY>", ["a?"; 40].join(", "))).unwrap();
|
||||
assert_eq!(validate(&dtd, &format!("<r>{}</r>", "<a/>".repeat(40))), vec![]);
|
||||
let problems = validate(&dtd, &format!("<r>{}</r>", "<a/>".repeat(41)));
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].message.starts_with("`r` contains a, a, a"), "{problems:?}");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
#[doc = include_str!("../README.md")]
|
||||
struct ReadmeExamples;
|
||||
|
||||
pub mod dtd;
|
||||
mod read;
|
||||
mod tree;
|
||||
pub mod value;
|
||||
|
||||
Reference in new issue
Block a user