PDFR: share parsing while retaining audited EPS fixes

This commit is contained in:
skylar taylor-barrick committed 2026-10-13 14:32:36 -06:00
1 parent 549e9d8251
commit a954ba4fa4
15 files changed
+773 -182

No files matched your search

Generated
+147
View File
@@ -747,6 +747,12 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.5.1"
@@ -962,6 +968,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"hayro-syntax",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1478,6 +1493,7 @@ dependencies = [
name = "effectcraft-pdf"
version = "0.9.2"
dependencies = [
"craft-pdf",
"effectcraft-raster",
"effectcraft-svg",
"effectcraft-text",
@@ -1485,6 +1501,7 @@ dependencies = [
"kurbo",
"miniz_oxide 0.8.9",
"skrifa",
"wasm-bindgen-test",
"zune-jpeg",
]
@@ -2678,6 +2695,40 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hayro-ccitt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
dependencies = [
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -3167,6 +3218,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3311,6 +3372,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
@@ -3759,6 +3829,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4773,6 +4849,17 @@ dependencies = [
"serde",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strum"
version = "0.28.0"
@@ -5156,6 +5243,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -5298,6 +5391,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -5462,6 +5570,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wasm-encoder"
version = "0.261.0"
+4
View File
@@ -11,6 +11,7 @@ rust-version = "1.95"
repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false, features = ["read"] }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
@@ -204,3 +205,6 @@ opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
+5
View File
@@ -15,3 +15,8 @@ list (author, source, license) is in ATTRIBUTION.md.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
+1
View File
@@ -154,6 +154,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
+4
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
craft-pdf.workspace = true
effectcraft-svg = { workspace = true }
kurbo = { workspace = true }
effectcraft-text = { workspace = true }
@@ -23,3 +24,6 @@ zune-jpeg = "0.5"
# Encodes JPEG fixtures for the DCT image test.
image = { workspace = true }
effectcraft-raster = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
+39 -9
View File
@@ -14,20 +14,23 @@ Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version
font programs; the **PostScript Language Reference, third edition** and the **Encapsulated
PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6**
(11/1988) for CCITT fax images. TrueType font programs are read through skrifa and JPEG (DCT)
images through zune-jpeg (both permissively licensed, pure Rust). No third-party
PDF/PostScript/font/codec code was read; test files (including the CFF and Type 1 fonts and the
CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
images through zune-jpeg (both permissively licensed, pure Rust). PDF document/object loading uses the shared `craft-pdf` parser described below. The retained
drawing, PostScript, font and filter implementations were developed from the public
specifications without copying third-party implementations. Test files (including the CFF
and Type 1 fonts and the CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like
ffmpeg elsewhere) to check the code tables against an independent implementation.
## What is read
- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference
sections do not matter), object streams, the page tree with inherited resources, MediaBox /
CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
RunLength, CCITTFax (Group 3 one- and two-dimensional, Group 4; `K`, `Columns`, `Rows`,
`EndOfLine`, `EncodedByteAlign`, `EndOfBlock`, `BlackIs1`; damaged data keeps the rows
decoded before the damage). Encrypted files are refused.
- **Structure:** `craft-pdf` resolves cross-reference tables/streams and indirect objects into
the app's existing object model. References retain object and generation numbers. A bounded
compatibility repair recovers referenced objects from object streams when damaged xrefs omit
them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages).
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`.
Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the
reader refuses a document.
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
@@ -129,3 +132,30 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
layer; the shapes drawn between images go to shape layers of their own (`<name> Outlines
<n>`), so the layer stack keeps the document's paint order. Soft masks, and the clips and
masks of images, are not converted (the command's `skipped` list says so).
## Shared parser setup and bounds
The workspace pins `craft-pdf` to `craft-libs` Git revision
`89dcbb3060377a26c99a6d80caa16084366704c4`, with `default-features = false` and only the explicit `read`
feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's
font and asset policies. The shared parser does not change the supported drawing operators,
image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter.
The root Cargo patch pins Hayro-syntax to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses
an enabled Hayro interpreter, rasterizer or CMap dependency. The shared revision must be
published before a fresh app checkout can resolve it; no sibling checkout is required.
Input is limited to 512 MiB. The object adapter admits at most 100,000 document/referenced
objects, 1,000,000 converted values and 64 inline nesting levels, with a 512 MiB retained
object-data budget. References are queued once rather than recursively expanded. Native
unwinding panics during shared loading or lazy object conversion become an error and discard
partial state. These limits are not an overall process-memory or execution-time guarantee;
WASM aborts and process-wide allocation failures cannot be caught. Missing or repaired objects
are reported rather than silently presented as a complete import.
The shared parser receives EffectCraft's existing ordinary object/xref stream limit at open:
256 MiB on 64-bit targets and 64 MiB on 32-bit targets, including WASM. Immutable document
limits apply during normal parsing, initial xrefs, fallback and repair. Malformed object-stream
compatibility recovery also obeys that limit and the adapter's remaining retained-data budget.
Content/image filter decoding still uses EffectCraft's existing bounded filter chain; the
retained-data cap is not a bound on every parser cache or temporary allocation.
+9 -6
View File
@@ -19,6 +19,7 @@ mod image;
mod object;
mod page;
mod ps;
mod read;
mod shading;
mod type1;
pub mod write;
@@ -33,6 +34,7 @@ pub enum Error {
NoPages,
PageOutOfRange(usize),
NoBoundingBox,
Read(String),
}
impl std::fmt::Display for Error {
@@ -41,8 +43,9 @@ impl std::fmt::Display for Error {
Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"),
Error::Encrypted => write!(f, "encrypted PDF files are not supported"),
Error::NoPages => write!(f, "the PDF has no pages"),
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1),
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n.saturating_add(1)),
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
Error::Read(message) => f.write_str(message),
}
}
}
@@ -99,7 +102,7 @@ pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
/// Number of pages (1 for EPS).
pub fn page_count(bytes: &[u8]) -> usize {
match sniff(bytes) {
Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(),
Some(Format::Pdf) => object::File::parse(bytes).map(|file| page::pages(&file).len()).unwrap_or(0),
Some(Format::Eps) => 1,
None => 0,
}
@@ -140,10 +143,7 @@ fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64,
}
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
let file = object::File::parse(bytes);
if file.encrypted() {
return Err(Error::Encrypted);
}
let file = object::File::parse(bytes)?;
let pages = page::pages(&file);
if pages.is_empty() {
return Err(Error::NoPages);
@@ -160,6 +160,7 @@ fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
};
let content = page::page_content(&file, pg);
let mut it = page::Interp::new(&file, pg.bbox);
it.b.skipped.extend(file.repairs.iter().cloned());
it.run(&content, &pg.resources, Affine::IDENTITY, 0);
let (nodes, flags, skipped) = it.b.finish();
Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc))
@@ -201,4 +202,6 @@ mod tests_groups;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_reader;
#[cfg(test)]
mod tests_shading;
+14 -159
View File
@@ -1,9 +1,6 @@
//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the
//! indirect-object table.
//!
//! The table is built by scanning the file for `n g obj` headers (later definitions win, as
//! incremental updates do) and by unpacking object streams, so damaged or rewritten
//! cross-reference sections do not matter.
//! Local object model, content-stream lexer and stream filters. Document objects are read
//! through the shared parser in `read`; the local lexer remains for content operators and
//! PostScript-related data.
use std::collections::HashMap;
@@ -191,7 +188,7 @@ impl<'a> Lexer<'a> {
}
/// `n g R` after a number inside arrays and dictionaries.
fn maybe_ref(&mut self, v: Obj) -> Obj {
pub(super) fn maybe_ref(&mut self, v: Obj) -> Obj {
let Obj::Num(n) = v else { return v };
if n.fract() != 0.0 || n < 0.0 {
return v;
@@ -312,11 +309,10 @@ pub fn parse_num(w: &[u8]) -> Option<f64> {
/// growing buffer can reserve about twice the limit.
const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 };
/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them,
/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and
/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and
/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above.
const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
/// The bound for ordinary object/xref streams and malformed object-stream recovery.
/// The shared parser receives this immutable limit when the document is opened;
/// 32-bit targets retain the app's quarter-sized limit.
pub(crate) const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
/// JBIG2) give `None`.
@@ -624,151 +620,14 @@ fn predict(data: &[u8], p: &Dict, max: usize) -> Option<Vec<u8>> {
/// Every indirect object of a PDF file, plus the trailer dictionaries found.
pub struct File {
pub objects: HashMap<u32, Obj>,
pub objects: HashMap<(u32, u16), Obj>,
pub trailers: Vec<Dict>,
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if from >= hay.len() || needle.is_empty() {
return None;
}
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
pub repairs: Vec<String>,
}
impl File {
pub fn parse(data: &[u8]) -> File {
let mut f = File { objects: HashMap::new(), trailers: vec![] };
let mut i = 0;
while let Some(p) = find(data, b"obj", i) {
i = p + 3;
if p >= 3 && &data[p - 3..p] == b"end" {
continue;
}
if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) {
continue;
}
// Walk back over "num gen ".
let mut q = p;
let back_ws = |q: &mut usize| {
let s = *q;
while *q > 0 && is_white(data[*q - 1]) {
*q -= 1;
}
*q < s
};
let back_digits = |q: &mut usize| {
let e = *q;
while *q > 0 && data[*q - 1].is_ascii_digit() {
*q -= 1;
}
(*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::<u32>().ok()).flatten()
};
if !back_ws(&mut q) {
continue;
}
let Some(_gen) = back_digits(&mut q) else { continue };
if !back_ws(&mut q) {
continue;
}
let Some(num) = back_digits(&mut q) else { continue };
if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) {
continue;
}
let mut lx = Lexer::new(data, p + 3);
let Some(obj) = lx.next() else { continue };
let obj = lx.maybe_ref(obj);
let obj = match obj {
Obj::Dict(d) => {
let save = lx.pos;
lx.skip_ws();
if data.get(lx.pos..).is_some_and(|d| d.starts_with(b"stream")) {
let mut s = lx.pos + 6;
if data.get(s) == Some(&b'\r') {
s += 1;
}
if data.get(s) == Some(&b'\n') {
s += 1;
}
let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize);
let s = s.min(data.len());
let end = match declared.filter(|l| {
s.checked_add(*l).is_some_and(|e| e <= data.len()) && {
let mut k = s + l;
while k < data.len() && is_white(data[k]) {
k += 1;
}
data[k..].starts_with(b"endstream")
}
}) {
Some(l) => s + l,
None => {
let mut e = find(data, b"endstream", s).unwrap_or(data.len());
if e > s && data[e - 1] == b'\n' {
e -= 1;
}
if e > s && data[e - 1] == b'\r' {
e -= 1;
}
e
}
};
let end = end.max(s);
i = end;
Obj::Stream(d, data[s..end].to_vec())
} else {
lx.pos = save;
Obj::Dict(d)
}
}
o => o,
};
if let Obj::Stream(d, _) = &obj
&& d.get("Type").and_then(Obj::name) == Some("XRef")
{
f.trailers.push(d.clone());
}
f.objects.insert(num, obj);
}
// Trailer dictionaries of classic cross-reference sections.
let mut i = 0;
while let Some(p) = find(data, b"trailer", i) {
i = p + 7;
let mut lx = Lexer::new(data, i);
if let Some(Obj::Dict(d)) = lx.next() {
f.trailers.push(d);
}
}
// Object streams.
let streams: Vec<(Dict, Vec<u8>)> = f
.objects
.values()
.filter_map(|o| match o {
Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())),
_ => None,
})
.collect();
for (d, raw) in streams {
let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue };
let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize;
let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize;
let mut lx = Lexer::new(&dec, 0);
let mut heads = vec![];
for _ in 0..n.min(100_000) {
let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break };
heads.push((num as u32, off as usize));
}
for (num, off) in heads {
if f.objects.contains_key(&num) || first + off >= dec.len() {
continue;
}
let mut lx = Lexer::new(&dec, first + off);
if let Some(o) = lx.next() {
let o = lx.maybe_ref(o);
f.objects.insert(num, o);
}
}
}
f
pub fn parse(data: &[u8]) -> Result<File, crate::Error> {
crate::read::open(data)
}
/// Follow references (bounded).
@@ -776,7 +635,7 @@ impl File {
let mut o = o;
for _ in 0..32 {
match o {
Obj::Ref(n, _) => match self.objects.get(n) {
Obj::Ref(n, g) => match self.objects.get(&(*n, *g)) {
Some(x) => o = x,
None => return &Obj::Null,
},
@@ -810,13 +669,9 @@ impl File {
return Some(d);
}
}
let mut cats: Vec<(&u32, &Obj)> =
let mut cats: Vec<(&(u32, u16), &Obj)> =
self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect();
cats.sort_by_key(|(n, _)| **n);
cats.last().and_then(|(_, o)| o.dict())
}
pub fn encrypted(&self) -> bool {
self.trailers.iter().any(|t| t.contains_key("Encrypt"))
}
}
+317
View File
@@ -0,0 +1,317 @@
//! Adapter from the shared PDF syntax API to the existing drawing interpreter's object model.
//! Only reachable objects are retained, references keep their generation, and native parser
//! panics (including lazy object reads) discard the entire partially populated adapter.
use std::collections::{HashMap, HashSet, VecDeque};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier, Stream};
use craft_pdf::syntax::xref::XRef;
use crate::Error;
use crate::object::{Dict, File, Lexer, Obj};
const MAX_OBJECTS: usize = 100_000;
const MAX_VALUES: usize = 1_000_000;
const MAX_DEPTH: usize = 64;
const MAX_BYTES: usize = 512 * 1024 * 1024;
fn limit(what: &str) -> Error {
Error::Read(format!("PDF exceeds the {what} import limit"))
}
fn allocation() -> Error {
Error::Read("PDF object storage could not be allocated".into())
}
fn open_options() -> craft_pdf::OpenOptions {
craft_pdf::OpenOptions {
decode_limits: craft_pdf::syntax::DecodeLimits::default().with_max_decoded_stream(crate::object::MAX_OBJECT_STREAM),
..Default::default()
}
}
pub(crate) fn open(bytes: &[u8]) -> Result<File, Error> {
craft_pdf::check_input_size_with(bytes.len(), &open_options()).map_err(|e| Error::Read(e.to_string()))?;
catch_unwind(AssertUnwindSafe(|| open_inner(bytes))).map_err(|_| Error::Read("PDF parser failed internally".into()))?
}
fn open_inner(bytes: &[u8]) -> Result<File, Error> {
// Preserve the app's refusal of encrypted PDFs, including empty-user-password files.
// Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below
// are read by the shared parser. This does not parse document objects a second time.
let mut markers = 0;
for (offset, word) in bytes.windows(7).enumerate() {
if word == b"trailer" {
markers += 1;
if markers > MAX_OBJECTS {
return Err(limit("trailer count"));
}
if let Some(Obj::Dict(dict)) = Lexer::new(bytes, offset + 7).next()
&& dict.contains_key("Encrypt")
{
return Err(Error::Encrypted);
}
}
}
let pdf = craft_pdf::open_slice_with_options(bytes, None, &open_options()).map_err(|error| match error {
craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Decryption(_)) => Error::Encrypted,
craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Invalid) => Error::NoPages,
other => Error::Read(other.to_string()),
})?;
let mut object_streams = Vec::new();
for (index, object) in pdf.objects().into_iter().enumerate() {
if index >= MAX_OBJECTS {
return Err(limit("100000 objects"));
}
if let Object::Stream(stream) = object {
let dict = stream.dict();
if dict.get::<Name<'_>>(b"Type").is_some_and(|name| name.as_ref() == b"XRef") && dict.contains_key(b"Encrypt") {
return Err(Error::Encrypted);
}
if dict.get::<Name<'_>>(b"Type").is_some_and(|name| name.as_ref() == b"ObjStm") {
object_streams.try_reserve(1).map_err(|_| allocation())?;
object_streams.push(stream);
}
}
}
let mut reader = Reader {
xref: pdf.xref(),
pending: VecDeque::new(),
seen: HashSet::new(),
values_left: MAX_VALUES,
bytes_left: MAX_BYTES,
object_streams,
recovery: None,
};
let root = pdf.xref().root_id();
let root = reader.reference(ObjRef::new(root.obj_number, root.gen_number))?;
let mut file = File { objects: HashMap::new(), trailers: vec![Dict::from([("Root".into(), root)])], repairs: Vec::new() };
while let Some(id) = reader.pending.pop_front() {
let Some(key) = key(id) else { continue };
let object = match reader.xref.get::<Object<'_>>(id) {
Some(value) => reader.object(value, 0)?,
None => {
let recovered = reader.recover(id)?;
if file.repairs.len() < 64 {
let action = if recovered.is_some() { "recovered PDF object from an object stream" } else { "unresolved PDF object" };
file.repairs.push(format!("{action}: {} {}", id.obj_number, id.gen_number));
}
recovered.unwrap_or(Obj::Null)
}
};
file.objects.try_reserve(1).map_err(|_| allocation())?;
file.objects.insert(key, object);
}
Ok(file)
}
fn key(id: ObjectIdentifier) -> Option<(u32, u16)> {
Some((u32::try_from(id.obj_number).ok()?, u16::try_from(id.gen_number).ok()?))
}
struct Reader<'a> {
xref: &'a XRef,
pending: VecDeque<ObjectIdentifier>,
seen: HashSet<ObjectIdentifier>,
values_left: usize,
bytes_left: usize,
object_streams: Vec<Stream<'a>>,
recovery: Option<HashMap<u32, Option<Recovered>>>,
}
struct Recovered {
bytes: Arc<Vec<u8>>,
start: usize,
end: usize,
}
impl Reader<'_> {
fn value(&mut self) -> Result<(), Error> {
self.values_left = self.values_left.checked_sub(1).ok_or_else(|| limit("1000000 values"))?;
Ok(())
}
fn bytes(&mut self, bytes: &[u8]) -> Result<Vec<u8>, Error> {
self.bytes_left = self.bytes_left.checked_sub(bytes.len()).ok_or_else(|| limit("512 MiB object data"))?;
let mut out = Vec::new();
out.try_reserve_exact(bytes.len()).map_err(|_| allocation())?;
out.extend_from_slice(bytes);
Ok(out)
}
/// The previous importer could recover compressed objects incorrectly marked free in
/// an otherwise readable xref. Retain that narrow repair only for unresolved references,
/// with a warning, bounded decoding and the existing content/object lexer.
fn recover(&mut self, id: ObjectIdentifier) -> Result<Option<Obj>, Error> {
if id.gen_number != 0 {
return Ok(None);
}
if self.recovery.is_none() {
let mut recovered: HashMap<u32, Option<Recovered>> = HashMap::new();
for stream in std::mem::take(&mut self.object_streams) {
let Some(count) = stream.dict().get::<u32>(b"N") else { continue };
let Some(first) = stream.dict().get::<u32>(b"First") else { continue };
if count as usize > MAX_OBJECTS.saturating_sub(recovered.len()) {
return Err(limit("100000 recovered objects"));
}
let decoded = match stream.decoded_within(self.bytes_left.min(crate::object::MAX_OBJECT_STREAM)) {
Ok(data) => data,
Err(_) => continue,
};
self.bytes_left = self.bytes_left.checked_sub(decoded.len()).ok_or_else(|| limit("512 MiB object data"))?;
let decoded = Arc::new(decoded.into_owned());
let mut lexer = Lexer::new(&decoded, 0);
let mut offsets = Vec::new();
for _ in 0..count {
self.value()?;
let (Some(Obj::Num(number)), Some(Obj::Num(offset))) = (lexer.next(), lexer.next()) else { break };
if !number.is_finite() || number.fract() != 0.0 || !(0.0..=f64::from(u32::MAX)).contains(&number) {
continue;
}
if !offset.is_finite() || offset.fract() != 0.0 || offset < 0.0 || offset > decoded.len() as f64 {
continue;
}
let Some(start) = (first as usize).checked_add(offset as usize).filter(|start| *start < decoded.len()) else { continue };
offsets.try_reserve(1).map_err(|_| allocation())?;
offsets.push((start, number as u32));
}
offsets.sort_unstable();
for (index, &(start, number)) in offsets.iter().enumerate() {
let end = offsets.get(index + 1).map_or(decoded.len(), |(next, _)| *next);
recovered.try_reserve(1).map_err(|_| allocation())?;
recovered.entry(number).and_modify(|entry| *entry = None).or_insert_with(|| Some(Recovered { bytes: Arc::clone(&decoded), start, end }));
}
}
self.recovery = Some(recovered);
}
let Some(number) = u32::try_from(id.obj_number).ok() else { return Ok(None) };
let Some(location) = self.recovery.as_ref().and_then(|r| r.get(&number)) else { return Ok(None) };
let Some(location) = location else { return Err(Error::Read(format!("PDF has ambiguous recovered object {number}"))) };
let bytes = Arc::clone(&location.bytes);
let Some(bytes) = bytes.get(location.start..location.end) else { return Ok(None) };
let mut lexer = Lexer::new(bytes, 0);
let Some(object) = lexer.next() else { return Ok(None) };
let object = lexer.maybe_ref(object);
self.register_recovered(&object, 0)?;
Ok(Some(object))
}
fn register_recovered(&mut self, object: &Obj, depth: usize) -> Result<(), Error> {
self.value()?;
if depth > MAX_DEPTH {
return Err(limit("64 nested objects"));
}
let bytes = match object {
Obj::Name(text) | Obj::Op(text) => text.len(),
Obj::Str(bytes) => bytes.len(),
Obj::Array(values) => {
for value in values {
self.register_recovered(value, depth + 1)?;
}
0
}
Obj::Dict(dict) | Obj::Stream(dict, _) => {
for (name, value) in dict {
self.bytes_left = self.bytes_left.checked_sub(name.len()).ok_or_else(|| limit("512 MiB object data"))?;
self.register_recovered(value, depth + 1)?;
}
0
}
Obj::Ref(number, generation) => {
if let Ok(number) = i32::try_from(*number) {
self.reference(ObjRef::new(number, i32::from(*generation)))?;
}
0
}
_ => 0,
};
self.bytes_left = self.bytes_left.checked_sub(bytes).ok_or_else(|| limit("512 MiB object data"))?;
Ok(())
}
fn name(&mut self, bytes: &[u8]) -> Result<String, Error> {
// Lossy UTF-8 expansion is at most three output bytes per source byte.
let bound = bytes.len().checked_mul(3).ok_or_else(|| limit("name length"))?;
self.bytes_left = self.bytes_left.checked_sub(bound).ok_or_else(|| limit("512 MiB object data"))?;
Ok(String::from_utf8_lossy(bytes).into_owned())
}
fn reference(&mut self, reference: ObjRef) -> Result<Obj, Error> {
self.value()?;
let id = ObjectIdentifier::new(reference.obj_number, reference.gen_number);
let Some((number, generation)) = key(id) else { return Ok(Obj::Null) };
if !self.seen.contains(&id) {
if self.seen.len() >= MAX_OBJECTS {
return Err(limit("100000 referenced objects"));
}
self.seen.try_reserve(1).map_err(|_| allocation())?;
self.pending.try_reserve(1).map_err(|_| allocation())?;
self.seen.insert(id);
self.pending.push_back(id);
}
Ok(Obj::Ref(number, generation))
}
fn maybe(&mut self, object: MaybeRef<Object<'_>>, depth: usize) -> Result<Obj, Error> {
match object {
MaybeRef::Ref(reference) => self.reference(reference),
MaybeRef::NotRef(object) => self.object(object, depth),
}
}
fn dictionary(&mut self, dict: &PdfDict<'_>, depth: usize) -> Result<Dict, Error> {
let mut out = Dict::new();
for (name, object) in dict.entries() {
let name = self.name(name.as_ref())?;
let object = self.maybe(object, depth + 1)?;
out.try_reserve(1).map_err(|_| allocation())?;
out.insert(name, object);
}
Ok(out)
}
fn object(&mut self, object: Object<'_>, depth: usize) -> Result<Obj, Error> {
self.value()?;
if depth > MAX_DEPTH {
return Err(limit("64 nested objects"));
}
Ok(match object {
Object::Null(_) => Obj::Null,
Object::Boolean(value) => Obj::Bool(value),
Object::Number(value) => Obj::Num(value.as_f64()),
Object::String(value) => Obj::Str(self.bytes(value.as_bytes())?),
Object::Name(value) => Obj::Name(self.name(value.as_ref())?),
Object::Array(array) => {
let mut out = Vec::new();
for value in array.raw_iter() {
let value = self.maybe(value, depth + 1)?;
out.try_reserve(1).map_err(|_| allocation())?;
out.push(value);
}
Obj::Array(out)
}
Object::Dict(dict) => Obj::Dict(self.dictionary(&dict, depth)?),
Object::Stream(stream) => {
let dict = self.dictionary(stream.dict(), depth)?;
Obj::Stream(dict, self.bytes(&stream.raw_data())?)
}
})
}
}
#[cfg(test)]
mod limit_tests {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn shared_reader_preserves_the_target_stream_budget() {
let options = super::open_options();
let expected = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
assert_eq!(options.decode_limits.max_decoded_stream(), expected);
assert_eq!(options.max_input_bytes, 512 << 20);
let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
let pdf = craft_pdf::open_slice_with_options(bytes, None, &options).unwrap();
assert_eq!(pdf.decode_limits().max_decoded_stream(), expected);
}
}
+30 -5
View File
@@ -48,7 +48,10 @@ fn pdf_paths_clip_gradient_and_layers() {
assert_eq!(page_count(&bytes), 1);
let doc = parse(&bytes).unwrap();
assert_eq!((doc.width, doc.height), (200.0, 100.0));
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}, skipped: {:?}", doc.skipped);
if objstm {
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object")), "the malformed xref repair is reported");
}
assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped);
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
@@ -196,7 +199,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() {
const MAX: usize = 1 << 20;
let flate = object::Obj::Name("FlateDecode".into());
let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect();
let file = object::File::parse(b"");
let file = object_file(vec![]);
let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6);
nested.resize(MAX - 1, 0);
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6);
@@ -206,7 +209,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() {
fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option<Vec<u8>> {
let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") };
object::decode_stream_within(&object::File::parse(b""), &d, raw, max)
object::decode_stream_within(&object_file(vec![]), &d, raw, max)
}
#[test]
@@ -660,9 +663,31 @@ fn tiling_patterns_coloured_and_uncoloured() {
assert_eq!(at(115, 15)[3], 0.0);
}
/// Build isolated interpreter objects without presenting a function or colour space as a
/// document catalog. Complete-document parsing is covered by the page and reader fixtures.
fn object_file(objs: Objs) -> object::File {
let objects = objs
.into_iter()
.map(|(number, body, stream)| {
let mut lexer = object::Lexer::new(body.as_bytes(), 0);
let value = lexer.next().unwrap();
let value = lexer.maybe_ref(value);
let value = match stream {
Some(bytes) => {
let object::Obj::Dict(dict) = value else { panic!("stream fixture must have a dictionary") };
object::Obj::Stream(dict, bytes)
}
None => value,
};
((number, 0), value)
})
.collect();
object::File { objects, trailers: vec![], repairs: vec![] }
}
/// Object 1 of a file holding just `objs`, read as a function.
fn read_function(objs: Objs) -> color::Func {
color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0))
color::Func::read(&object_file(objs), &object::Obj::Ref(1, 0))
}
/// A sampled (type 0) function, read from a file holding just its stream.
@@ -672,7 +697,7 @@ fn read_sampled_function(dict: &str, data: Vec<u8>) -> color::Func {
/// Object 1 of a file holding just `objs`, read as a colour space.
fn read_color_space(objs: Objs) -> color::Cs {
color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None)
color::color_space(&object_file(objs), &object::Obj::Ref(1, 0), None)
}
#[test]
+6
View File
@@ -376,3 +376,9 @@ fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() {
let _ = parse(src.as_bytes());
}
}
#[test]
fn extreme_page_indices_have_a_printable_error() {
let text = Error::PageOutOfRange(usize::MAX).to_string();
assert!(text.starts_with("the PDF has no page "));
}
+72
View File
@@ -0,0 +1,72 @@
//! The shared object adapter preserves scalar references and bounds both tree and graph input.
use crate::{Error, parse};
fn document(extra: &str, tail: &[(u32, u16, String)]) -> Vec<u8> {
let content = "/GS gs 1 0 0 rg 0 0 20 10 re f";
let mut objects = vec![
(1, 0, format!("<< /Type /Catalog /Pages 2 0 R {extra} >>")),
(2, 0, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
(3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 10] /Resources << /ExtGState << /GS << /ca 5 7 R >> >> >> /Contents 4 0 R >>".into()),
(4, 0, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())),
(5, 7, "0.25".into()),
];
objects.extend_from_slice(tail);
let mut data = b"%PDF-1.7\n".to_vec();
let mut offsets = std::collections::BTreeMap::new();
for (number, generation, value) in objects {
offsets.insert(number, (data.len(), generation));
data.extend_from_slice(format!("{number} {generation} obj\n{value}\nendobj\n").as_bytes());
}
let xref = data.len();
let count = offsets.keys().last().copied().unwrap() + 1;
data.extend_from_slice(format!("xref\n0 {count}\n0000000000 65535 f \n").as_bytes());
for number in 1..count {
let entry = offsets.get(&number).map_or_else(|| "0000000000 65535 f \n".into(), |(offset, generation)| format!("{offset:010} {generation:05} n \n"));
data.extend_from_slice(entry.as_bytes());
}
data.extend_from_slice(format!("trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
data
}
#[test]
fn a_nonzero_generation_scalar_keeps_the_drawing_opacity() {
let doc = parse(&document("", &[])).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let pixel = effectcraft_svg::rasterize(&doc, 20, 10, 1.0).get(10, 5);
assert!((pixel[0] - 0.25).abs() < 0.001 && (pixel[3] - 0.25).abs() < 0.001, "{pixel:?}");
}
#[test]
fn a_long_cyclic_reference_graph_does_not_expand_recursively() {
let objects: Vec<_> = (10..1010).map(|id| (id, 0, format!("<< /Next {} 0 R >>", if id == 1009 { 10 } else { id + 1 }))).collect();
let plain = parse(&document("", &[])).unwrap();
let cyclic = parse(&document("/Extra 10 0 R", &objects)).unwrap();
assert_eq!(plain, cyclic);
}
#[test]
fn excessive_inline_nesting_returns_an_error_instead_of_partial_art() {
let extra = format!("/Extra {}0{}", "[".repeat(80), "]".repeat(80));
assert!(matches!(parse(&document(&extra, &[])), Err(Error::Read(message)) if message.contains("64 nested objects")));
}
fn object_stream(entries: &[(u32, &str)]) -> String {
let (mut header, mut body) = (String::new(), String::new());
for (number, value) in entries {
header.push_str(&format!("{number} {} ", body.len()));
body.push_str(value);
body.push(' ');
}
let first = header.len();
let content = header + &body;
format!("<< /Type /ObjStm /N {} /First {first} /Length {} >>\nstream\n{content}\nendstream", entries.len(), content.len())
}
#[test]
fn duplicate_unreferenced_compressed_objects_do_not_block_repair() {
let objects = vec![(20, 0, object_stream(&[(10, "<< /Value 1 >>"), (30, "null")])), (21, 0, object_stream(&[(30, "42")]))];
let doc = parse(&document("/Missing 10 0 R", &objects)).unwrap();
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object from an object stream: 10 0")));
assert!(matches!(parse(&document("/Missing 30 0 R", &objects)), Err(Error::Read(message)) if message.contains("ambiguous recovered object 30")));
}
+2
View File
@@ -721,3 +721,5 @@ JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected |
PDF document structure uses the `craft-pdf` read feature through an app-owned bounded object adapter. EffectCraft retains its drawing tree, fonts, content/image filters and EPS interpreter; ordinary object/xref limits remain 256 MiB on 64-bit and 64 MiB on 32-bit/WASM, including repair.
+6 -3
View File
@@ -1,4 +1,4 @@
//! Workspace automation: `cargo xtask <layers|assets|wasm|web|ico|ci>`.
//! Workspace automation: `cargo xtask <layers|assets|pdf-deps|wasm|web|ico|ci>`.
//!
//! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges,
//! listed same-layer edges, no UI/OS crates below L5).
@@ -9,12 +9,13 @@
//! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete
//! `<file>.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1).
//! - `ico <out.ico> <in.png>…`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`).
//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm.
//! - `ci`: fmt check, clippy -D warnings, tests, PDF dependency policy, layers, assets, wasm.
//! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios
//! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md).
mod bench;
mod ico;
mod pdf_deps;
mod version;
use std::process::{Command, ExitCode};
@@ -410,6 +411,7 @@ fn ci() -> Result<(), String> {
run(Command::new(cargo).args(["fmt", "--check"]))?;
run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?;
run(Command::new(cargo).args(["test", "--workspace", "--release"]))?;
pdf_deps::run(&root())?;
layers()?;
assets()?;
wasm()
@@ -421,6 +423,7 @@ fn main() -> ExitCode {
"layers" => layers(),
"wasm" => wasm(),
"assets" => assets(),
"pdf-deps" => pdf_deps::run(&root()),
"ci" => ci(),
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
@@ -432,7 +435,7 @@ fn main() -> ExitCode {
let rest: Vec<String> = std::env::args().skip(2).collect();
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
}
_ => Err("usage: cargo xtask <layers|assets|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
+117
View File
@@ -0,0 +1,117 @@
//! Pin the shared parser and reject interpreter features that would embed backend assets.
use std::path::Path;
use std::process::Command;
use serde_json::Value;
const REV: &str = "89dcbb3060377a26c99a6d80caa16084366704c4";
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2")];
pub fn run(root: &Path) -> Result<(), String> {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args(["metadata", "--locked", "--format-version", "1"])
.output()
.map_err(|e| format!("cargo metadata: {e}"))?;
if !output.status.success() {
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
}
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
validate(&metadata)?;
println!("PDF backend: pinned parser only; no Hayro interpreter, rasterizer or bundled CMaps");
Ok(())
}
fn validate(metadata: &Value) -> Result<(), String> {
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}");
for (name, version) in PACKAGES {
let matches: Vec<_> = packages.iter().filter(|p| p["name"] == *name).collect();
if matches.len() != 1 || matches[0]["version"] != *version || matches[0]["source"] != source {
return Err(format!("{name}: expected exactly {version} from {source}"));
}
}
for (name, allowed) in [("craft-pdf", "read"), ("hayro-syntax", "std")] {
let package = packages.iter().find(|p| p["name"] == name).ok_or_else(|| format!("missing {name}"))?;
let node = nodes.iter().find(|n| n["id"] == package["id"]).ok_or_else(|| format!("{name}: missing resolved node"))?;
let features = node["features"].as_array().ok_or_else(|| format!("{name}: missing resolved features"))?;
if features.len() != 1 || features.first().and_then(Value::as_str) != Some(allowed) {
return Err(format!("{name}: expected only the {allowed} feature"));
}
}
for package in packages {
let name = package["name"].as_str().unwrap_or("");
let active = nodes.iter().any(|node| node["id"] == package["id"]);
if active && matches!(name, "hayro" | "hayro-interpret" | "hayro-cmap" | "krilla") {
return Err(format!("{name}: EffectCraft must use craft-pdf with only the read feature"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn metadata() -> Value {
let packages: Vec<_> = PACKAGES.iter().map(|(name, version)| {
json!({"id": name, "name": name, "version": version, "source": format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})
}).collect();
json!({"packages": packages, "resolve": {"nodes": [
{"id": "hayro-syntax", "features": ["std"]}, {"id": "craft-pdf", "features": ["read"]}
]}})
}
#[test]
fn accepts_the_pinned_parser_only_graph() {
assert!(validate(&metadata()).is_ok());
}
#[test]
fn rejects_unpinned_duplicate_and_missing_parsers() {
let mut m = metadata();
m["packages"][0]["source"] = json!("registry");
assert!(validate(&m).is_err());
let mut m = metadata();
let duplicate = m["packages"][0].clone();
m["packages"].as_array_mut().unwrap().push(duplicate);
assert!(validate(&m).is_err());
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
assert!(validate(&m).is_err());
}
#[test]
fn rejects_shared_reader_source_drift() {
let mut m = metadata();
m["packages"][0]["source"] = Value::Null;
assert!(validate(&m).is_err());
}
#[test]
fn rejects_extra_features_and_missing_active_nodes() {
for (index, feature) in [(0, "images"), (1, "write"), (1, "raster")] {
let mut m = metadata();
m["resolve"]["nodes"][index]["features"].as_array_mut().unwrap().push(json!(feature));
assert!(validate(&m).is_err());
}
let mut m = metadata();
m["resolve"]["nodes"].as_array_mut().unwrap().pop();
assert!(validate(&m).is_err());
}
#[test]
fn rejects_backend_asset_features_and_missing_resolution() {
for name in ["hayro", "hayro-interpret", "hayro-cmap", "krilla"] {
let mut m = metadata();
m["packages"].as_array_mut().unwrap().push(json!({"id": name, "name": name}));
m["resolve"]["nodes"].as_array_mut().unwrap().push(json!({"id": name}));
assert!(validate(&m).is_err());
}
let mut m = metadata();
m["resolve"] = Value::Null;
assert!(validate(&m).is_err());
}
}