diff --git a/Cargo.lock b/Cargo.lock index ce8fe123..b6ae1910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +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.10.0" 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=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +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" diff --git a/Cargo.toml b/Cargo.toml index 9f4e9d17..5bcb4bf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", 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 = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" } diff --git a/NOTICE b/NOTICE index 65ce4c42..43bd2e2a 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index a579491a..e21039c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 9b761f57..b37dff50 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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" diff --git a/crates/pdf/README.md b/crates/pdf/README.md index b7678b09..57603cf9 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -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,33 @@ ffmpeg elsewhere) to check the code tables against an independent implementation layer; the shapes drawn between images go to shape layers of their own (` Outlines `), 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 +`ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d`, 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. +This revision removes an unchecked lazy-array read and preserves later values and pages after +sign-only numeric entries; the app exercises those inputs through its object adapter, public +page parser and rasterizer on native and WASM targets. + +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. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 39d15074..f4616a9a 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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 { /// 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, flags: Vec, skipped: Vec, width: f64, } fn parse_pdf(bytes: &[u8], index: usize) -> Result { - 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 { }; 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; diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index b9016222..cdbb9733 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -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 { /// 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> { /// Every indirect object of a PDF file, plus the trailer dictionaries found. pub struct File { - pub objects: HashMap, + pub objects: HashMap<(u32, u16), Obj>, pub trailers: Vec, -} - -fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { - 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, } 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::().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)> = 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 { + 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")) - } } diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs new file mode 100644 index 00000000..953582fc --- /dev/null +++ b/crates/pdf/src/read.rs @@ -0,0 +1,350 @@ +//! 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 { + 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 { + // 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::>(b"Type").is_some_and(|name| name.as_ref() == b"XRef") && dict.contains_key(b"Encrypt") { + return Err(Error::Encrypted); + } + if dict.get::>(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::>(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, + seen: HashSet, + values_left: usize, + bytes_left: usize, + object_streams: Vec>, + recovery: Option>>, +} + +struct Recovered { + bytes: Arc>, + 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, 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, Error> { + if id.gen_number != 0 { + return Ok(None); + } + if self.recovery.is_none() { + let mut recovered: HashMap> = HashMap::new(); + for stream in std::mem::take(&mut self.object_streams) { + let Some(count) = stream.dict().get::(b"N") else { continue }; + let Some(first) = stream.dict().get::(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 { + // 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 { + 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>, depth: usize) -> Result { + match object { + MaybeRef::Ref(reference) => self.reference(reference), + MaybeRef::NotRef(object) => self.object(object, depth), + } + } + + fn dictionary(&mut self, dict: &PdfDict<'_>, depth: usize) -> Result { + 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 { + 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_tolerant_array_numbers_and_later_pages() { + use crate::object::Obj; + + // Array validation accepts these sign/dot-only zero tokens. Previously '+' + // reached an unchecked lazy-iterator read: native loading returned an internal + // parser error and WASM could abort before reaching the following object. + for token in ["+", "-", ".", "+.", "-."] { + for numeric_kid in [false, true] { + let kids = if numeric_kid { format!("{token} 3 0 R") } else { "3 0 R".into() }; + let bytes = crate::write::pdf( + &[ + (1, format!("<< /Type /Catalog /Pages 2 0 R /Probe [{token} 3] >>"), None), + (2, format!("<< /Type /Pages /Kids [{kids}] /Count 1 >>"), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Resources << >> /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 40 20 re f\n".to_vec())), + ], + 1, + ); + let file = super::open(&bytes).unwrap(); + assert_eq!(file.catalog().unwrap().get("Probe").and_then(Obj::array), Some([Obj::Num(0.0), Obj::Num(3.0)].as_slice())); + assert_eq!(crate::page_count(&bytes), 1, "token {token}, numeric kid {numeric_kid}"); + let doc = crate::parse(&bytes).unwrap(); + assert_eq!((doc.width, doc.height), (40.0, 20.0)); + assert_eq!(doc.flatten().len(), 1); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(effectcraft_svg::rasterize(&doc, 40, 20, 1.0).get(20, 10), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(crate::parse_page(&bytes, 1), Err(crate::Error::PageOutOfRange(1))); + } + } + } + + #[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); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 57b7e044..a3dcbb21 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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> { 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) -> 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] diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index add447e3..e53727ef 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -377,6 +377,12 @@ fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() { } } +#[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 ")); +} + #[test] fn postscript_idiv_and_mod_of_i64_min_by_minus_one_give_zero() { // `i64::MIN / -1` and `i64::MIN % -1` overflow, and `/` and `%` panic on them in debug and diff --git a/crates/pdf/src/tests_reader.rs b/crates/pdf/src/tests_reader.rs new file mode 100644 index 00000000..999a9900 --- /dev/null +++ b/crates/pdf/src/tests_reader.rs @@ -0,0 +1,76 @@ +//! 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 { + 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)); + let bytes = document(&extra, &[]); + // The shared parser rejects the catalog before the adapter's depth limit runs. + // Its Invalid result follows the existing NoPages mapping, with no partial document. + assert_eq!(crate::page_count(&bytes), 0); + assert_eq!(parse(&bytes), Err(Error::NoPages)); +} + +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"))); +} diff --git a/docs/architecture.md b/docs/architecture.md index e5564c22..72daec32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4d541feb..e79ac63a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,4 +1,4 @@ -//! Workspace automation: `cargo xtask `. +//! Workspace automation: `cargo xtask `. //! //! - `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 //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). //! - `ico …`: 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::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), @@ -432,7 +435,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs new file mode 100644 index 00000000..d7f88079 --- /dev/null +++ b/xtask/src/pdf_deps.rs @@ -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 = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"; +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()); + } +}