diff --git a/Cargo.lock b/Cargo.lock index 6aa2be432..469e5b899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2074,13 +2074,13 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", + "log", "pic-scale", "vello_cpu 0.0.8", ] @@ -2104,8 +2104,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2115,7 +2114,7 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.47.0", "smallvec", "yoke", ] @@ -2123,8 +2122,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2148,8 +2146,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "flate2", "hayro-ccitt", @@ -2158,6 +2155,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -4265,6 +4263,17 @@ dependencies = [ "float-cmp", ] +[[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 = "subsetter" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 64dd662a5..a6e277fcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,3 +118,8 @@ debug = 0 # once eframe depends on a winit release with the fix. [patch.crates-io] winit = { git = "https://github.com/rust-windowing/winit", rev = "39c4009c41e5977d779cf8a365792c8153ca77c5" } +# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. +hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } diff --git a/NOTICE b/NOTICE index 972ab4567..5e37b0d72 100644 --- a/NOTICE +++ b/NOTICE @@ -64,6 +64,11 @@ list (author, source, license) is in ASSETS.md. Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. +- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 + (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at + the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license + (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. diff --git a/ROADMAP.md b/ROADMAP.md index 17a9cff59..e6198e468 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change. - **2026-10-13** · Commands refuse parameter keys their params description doesn't name (#776): the control channel, MCP (`-32602`, every `batch` step checked before the first runs) and `designcraft-cli --cmd` answer with an “unknown parameter” error naming the nearest or the accepted keys instead of running with defaults; journals still replay. Descriptions of the table, anchored-object, data-merge and placing commands now name every key they read. No readiness percentage change. - **2026-10-13** · File ▸ Open Recent lists the documents last opened or saved (newest first, no duplicates; Save a Copy and exports don't count), with Clear Recent Files; the start screen shows the last eight, below the community card, as cards with a picture of the first page: saving puts one in the file (`preview.png`), so the start screen reads it without opening the document. A file that is gone is dimmed and says so when chosen, and stays listed. Preferences ▸ File Handling sets how many are kept (default 20, 0–100). `file.openRecent {index}`, `file.recentFiles`, `file.clearRecent` (#706). No readiness percentage change. - **2026-10-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). No readiness percentage change. diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs new file mode 100644 index 000000000..89a8fa53b --- /dev/null +++ b/crates/render/tests/placed_pdf.rs @@ -0,0 +1,261 @@ +//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh +//! shadings with points far off the page, ten thousand overlapping triangles or forty thousand +//! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is +//! drawn. A page with a `/UserUnit` is sized in points, and so are its boxes. The function and mesh +//! cases follow pdfcraft's tests in `crates/render/src/raster.rs`. + +use std::sync::Arc; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::Duration; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_doc::PdfCrop; +use designcraft_render::{Rendered, Renderer, pdf_crop_box, pdf_page_size}; +use hayro::hayro_interpret::Function; +use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// An exponential function of one input with one output. +const LEAF: &str = "<>"; + +/// Mesh entries common to the shadings below: RGB colors, 8-bit coordinates and components. +const MESH: &str = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8"; + +/// One triangle of a free-form triangle mesh: three vertices of a flag, x, y and a color. +const TRIANGLE: [u8; 18] = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255]; + +/// A stream object with the `dict` entries, its `/Length` and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut s = format!("<<{dict} /Length {}>>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + s +} + +/// A one-page PDF whose page dictionary holds the `page` entries (boxes, resources) and whose page +/// draws `content`. The `objects` are numbered from 5. +fn pdf(page: &str, content: &str, objects: &[Vec]) -> Vec { + let mut bodies = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>").into_bytes(), + stream("", content.as_bytes()), + ]; + bodies.extend_from_slice(objects); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", bodies.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +/// The page of a document `size` points square with `pdf` placed to fill it, rendered by +/// `Renderer::render_page` at one pixel per point. Fails when the render panics or takes more +/// than a minute. +fn placed(size: f64, pdf: Vec) -> Rendered { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = Document::new(&NewDocument { width: size, height: size, facing_pages: false, ..Default::default() }); + let id = AssetId(d.alloc()); + let asset = d.add_asset(Asset { id, name: "art.pdf".into(), mime: "application/pdf".into(), data: Arc::new(pdf), ..Default::default() }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); + item.content = + Content::Graphic(Graphic { + asset, + size: (size, size), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let _ = tx.send(renderer.render_page(&d, &Cache::new(), 0, 1.0, false, &Default::default())); + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(page) => page.expect("the page renders"), + Err(RecvTimeoutError::Timeout) => panic!("the placed PDF did not render within a minute"), + Err(RecvTimeoutError::Disconnected) => panic!("the render of the placed PDF panicked"), + } +} + +/// A 40-point page that paints an axial shading through the first of `functions` (objects 6 +/// onwards), then a blue square over the whole page. +fn shaded(functions: &[Vec]) -> Vec { + let mut objects = vec![b"<>".to_vec()]; + objects.extend_from_slice(functions); + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/S sh 0 0 1 rg 0 0 40 40 re f", &objects) +} + +/// A calculator (Type 4) function of one input that runs `program`. +fn calculator(program: &str) -> Vec { + stream("/FunctionType 4/Domain[0 1]/Range[0 1 0 1 0 1]", program.as_bytes()) +} + +/// The function that is object 5 of a PDF holding `objects`. +fn function(objects: &[&str]) -> Option { + let objects: Vec> = objects.iter().map(|o| o.as_bytes().to_vec()).collect(); + let parsed = Pdf::new(pdf("/MediaBox[0 0 20 20]", "", &objects)).unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + Function::new(&object) +} + +#[test] +fn placed_pdf_function_dividing_by_zero_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop 1 0 idiv }")])).pixel(20, 20), BLUE); +} + +#[test] +fn placed_pdf_function_dividing_the_smallest_integer_by_minus_one_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop -2147483648 -1 idiv }")])).pixel(20, 20), BLUE); +} + +/// A chain of 100,000 functions: each stitching function names the next by reference, and the last +/// is an exponential function. +#[test] +fn placed_pdf_chain_of_stitching_functions_leaves_the_page_drawn() { + let mut functions: Vec> = + (7..100_006).map(|next| format!("<>").into_bytes()).collect(); + functions.push(b"<>".to_vec()); + assert_eq!(placed(40.0, shaded(&functions)).pixel(20, 20), BLUE); +} + +/// Stitching functions that each name the next through `N 0 R`: 64 evaluate, 65 are refused. +#[test] +fn function_limits_depth_guard_refuses_a_chain_of_references() { + for functions in [64, 65] { + let objects: Vec = (5..5 + functions) + .map(|number| { + if number < 4 + functions { + format!("<>", number + 1) + } else { + LEAF.to_owned() + } + }) + .collect(); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let chain = function(&objects); + if functions == 64 { + assert_eq!(chain.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(chain.is_none(), "65 functions are one past the function-depth guard"); + } + } +} + +/// Stitching functions with a cycle, no children or a missing child are refused; one child named +/// twice still evaluates. +#[test] +fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { + for objects in [ + &["<>"][..], + &[ + "<>", + "<>", + ], + &["<>"], + &["<>"], + &["<>", LEAF], + ] { + assert!(function(objects).is_none(), "accepted invalid children: {objects:?}"); + } + let shared = function(&["<>", LEAF]).unwrap(); + let output = shared.eval([0.75].into_iter().collect()).unwrap(); + assert!((output[0] - 0.5).abs() < 0.001); +} + +/// Ten thousand triangles that each cover half of the placed page, which is drawn 1000 pixels +/// square. +#[test] +fn many_overlapping_mesh_triangles_finish() { + let shading = stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 /Decode [0 125 0 125 0 1 0 1 0 1]"), &TRIANGLE.repeat(10_000)); + let page = placed(125.0, pdf("/MediaBox[0 0 125 125]/Resources<>>>", "/Sh0 sh", &[shading])); + let p = page.pixel(12, 112); + assert!(p != WHITE && p[3] == 255, "the triangles are drawn: {p:?}"); +} + +/// One Coons patch: its flag, twelve points around a square of 8-bit coordinates, four colors. +fn coons() -> Vec { + let mut patch = vec![0u8]; + for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] { + patch.extend_from_slice(&[p.0, p.1]); + } + patch.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]); + patch +} + +/// A 40-point page that paints `shading` (object 5), then a red square at its bottom left. +fn meshed(shading: Vec) -> Vec { + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/Sh0 sh 1 0 0 rg 0 0 4 4 re f", &[shading]) +} + +/// Meshes whose points lie far off the page are sampled only where the page shows them. +#[test] +fn mesh_shadings_far_off_the_page_render() { + let far = "/Decode [-40000 40000 -40000 40000 0 1 0 1 0 1]"; + for (what, shading) in [ + ("a Coons patch", stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 {far}"), &coons())), + ("a triangle mesh", stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 {far}"), &TRIANGLE)), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "{what} far off the page: the rest of the page draws"); + } + for (what, decode) in [("on the page", "[0 40 0 40 0 1 0 1 0 1]"), ("covering the page", "[-40000 40000 -40000 40000 0 1 0 1 0 1]")] { + let page = placed(40.0, meshed(stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode {decode}"), &coons()))); + let center = page.pixel(20, 20); + assert!(center != WHITE && center[3] == 255, "a patch {what} is shaded: {center:?}"); + } + // A patch filling a 4 × 4 pt rectangle as a pattern is still shaded. + let shading = stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [10 14 10 14 0 1 0 1 0 1]"), &coons()); + let page = placed( + 40.0, + pdf("/MediaBox[0 0 40 40]/Resources<>>>>>", "/Pattern cs /P0 scn 10 10 4 4 re f", &[shading]), + ); + let middle = page.pixel(12, 28); + assert!(middle != WHITE && middle[3] == 255, "a small patch is shaded: {middle:?}"); +} + +/// Forty thousand Coons patches beside the page, and a lattice with no vertices per row. +#[test] +fn mesh_shadings_with_many_patches_or_no_rows_finish() { + for shading in [ + stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [100 140 100 140 0 1 0 1 0 1]"), &coons().repeat(40_000)), + stream(&format!("/ShadingType 5 {MESH} /VerticesPerRow 0 /Decode [0 40 0 40 0 1 0 1 0 1]"), &[0; 64]), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "the rest of the page draws"); + } +} + +/// A page with `/UserUnit 2` is twice its media box in points, and the crop boxes import offers +/// are measured in points as well. +#[test] +fn a_page_with_a_user_unit_and_its_boxes_are_sized_in_points() { + let page = pdf("/MediaBox[0 0 100 50]/TrimBox[10 10 90 40]/UserUnit 2", "", &[]); + assert_eq!(pdf_page_size(&page, 0), Some((200.0, 100.0))); + let (frac, size) = pdf_crop_box(&page, 0, PdfCrop::Trim).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!(frac.iter().zip([0.1, 0.2, 0.9, 0.8]).all(|(&a, b)| close(a, b)), "{frac:?}"); + assert!(close(size.0, 160.0) && close(size.1, 60.0), "{size:?}"); + assert_eq!(pdf_crop_box(&page, 0, PdfCrop::Media), Some(([0.0, 0.0, 1.0, 1.0], (200.0, 100.0)))); +}