From da0e87b86b372b6d201a1f1bd0dedf5030c25650 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 09:11:04 -0400 Subject: [PATCH] =?UTF-8?q?H:=20place=20PDF=20=E2=80=94=20sized=20by=20cro?= =?UTF-8?q?p=20box,=20hayro=20on=20screen,=20vector=20form=20XObjects=20in?= =?UTF-8?q?=20PDF=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 101 ++++++++++++++++++++++++++++ Cargo.toml | 3 +- apps/designcraft-web/src/web.rs | 2 +- apps/designcraft/src/main.rs | 4 +- crates/engine/src/cmd/links.rs | 4 ++ crates/engine/src/cmd/place_text.rs | 44 ++++++++++++ crates/pdf/src/export.rs | 23 +++++++ crates/render/Cargo.toml | 1 + crates/render/src/lib.rs | 44 ++++++++++++ 9 files changed, 222 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60b62d5..bde1089 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1029,6 +1029,7 @@ dependencies = [ "designcraft-doc", "designcraft-fonts", "designcraft-geom", + "hayro", "image", "log", "vello_common 0.2.0", @@ -1667,6 +1668,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glifo" version = "0.2.0" @@ -1858,6 +1876,20 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "vello_cpu 0.0.8", +] + [[package]] name = "hayro-ccitt" version = "0.3.0" @@ -1934,6 +1966,17 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "hayro-write" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" +dependencies = [ + "flate2", + "hayro-syntax", + "pdf-writer", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2256,6 +2299,7 @@ dependencies = [ "flate2", "float-cmp", "gif", + "hayro-write", "image-webp", "imagesize", "indexmap", @@ -3018,6 +3062,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4197,6 +4251,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_common" version = "0.1.0" @@ -4228,6 +4316,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo 0.1.1", + "hashbrown 0.17.1", + "png", + "vello_common 0.0.8", +] + [[package]] name = "vello_cpu" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e670e85..4a93e9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,8 @@ harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff"] } -krilla = "0.8" +krilla = { version = "0.8", features = ["pdf"] } +hayro = "0.7" quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index d17617b..3b7ac97 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -5,7 +5,7 @@ use designcraft_ui_egui::{DesignApp, Inbox, Services}; use wasm_bindgen::JsCast as _; const DOC_EXTS: &[&str] = &["designcraft", "idml"]; -const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "txt", "docx", "rtf", "md"]; +const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "pdf", "txt", "docx", "rtf", "md"]; const CANVAS_ID: &str = "designcraft_canvas"; const LOADING_ID: &str = "designcraft_loading"; diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index feb425f..e250f7c 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -82,8 +82,8 @@ fn services() -> Services { pick_open: Some(Box::new(|purpose: &str| { let d = rfd::FileDialog::new(); let d = if purpose == "place" { - d.add_filter("Graphics and text", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "txt", "docx", "rtf", "md"]) - .add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"]) + d.add_filter("Graphics and text", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "pdf", "txt", "docx", "rtf", "md"]) + .add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "pdf"]) .add_filter("Text (Word, RTF, plain)", &["docx", "rtf", "txt", "md"]) } else { d.add_filter("DesignCraft or IDML", &["designcraft", "idml"]) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 6b24815..89d087c 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -114,6 +114,10 @@ fn list(d: &Document) -> Vec { let loc = d.find(id)?; let it = d.item_at(&loc)?; let Content::Graphic(g) = &it.content else { return None }; + // Vector (placed PDF): no effective resolution. + if a.mime == "application/pdf" { + return None; + } let (pw, _) = a.pixels?; let xf = d.parent_xf(&loc) * it.xf * g.xf; let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width(); diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 634705a..91568e3 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -180,3 +180,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod place_pdf_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_pdf_renders_and_exports_as_vector() { + let dir = std::env::temp_dir().join(format!("dc-place-pdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("ad.pdf"); + // A PDF made by our own exporter: a black square on a page. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + s.execute("file.exportPdf", &json!({"path": src.to_string_lossy()})).unwrap(); + // Place it in a new document. + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("file.place", &json!({"path": src.to_string_lossy(), "x": 100, "y": 100})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.spreads[0].items.last().unwrap(); + let b = it.bounds(); + assert!((b.width() - 200.0).abs() < 1.0 && (b.height() - 100.0).abs() < 1.0, "sized by the PDF page: {b:?} {r}"); + let asset = d.assets.values().next().unwrap(); + assert_eq!(asset.mime, "application/pdf"); + // On screen: the square is black, the rest of the placed page white. + let mut rr = designcraft_render::Renderer::new(); + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + assert!(img.pixel(150, 150)[0] < 90, "{:?}", img.pixel(150, 150)); + assert!(img.pixel(250, 150)[0] > 200, "{:?}", img.pixel(250, 150)); + // Exported: still a valid PDF, larger than a page without the placed PDF would be. + let out = dir.join("out.pdf"); + s.execute("file.exportPdf", &json!({"path": out.to_string_lossy()})).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).expect("valid pdf"); + assert_eq!(pdf.pages().len(), 1); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Subtype /Form") || text.contains("/Subtype/Form"), "embedded as a form XObject"); + assert!(!text.contains("/Subtype /Image") && !text.contains("/Subtype/Image"), "not rasterized"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 38773c0..58dead4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -146,6 +146,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) clip: Rect::ZERO, warnings, images: HashMap::new(), + pdfs: HashMap::new(), fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: archival.is_some(), @@ -224,6 +225,8 @@ pub(crate) struct Exporter<'a> { pub clip: Rect, pub warnings: Vec, images: HashMap>, + /// Placed PDFs (embedded as vector pages). + pdfs: HashMap>, pub fonts: HashMap>, pub reverse_cmaps: HashMap>>, /// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet). @@ -683,6 +686,26 @@ impl Exporter<'_> { } fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + // Placed PDFs go in as vectors (the page as a form XObject). + if let Some(asset) = self.doc.assets.get(&g.asset) + && asset.data.starts_with(b"%PDF") + { + let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return }; + let doc = self + .pdfs + .entry(g.asset) + .or_insert_with(|| krilla::pdf::Pdf::new(asset.data.clone()).ok().map(|p| krilla::pdf::PdfDocument::new(Arc::new(p)))) + .clone(); + match doc { + Some(doc) => { + s.push_transform(&tf(g.xf)); + s.draw_pdf_page(&doc, size, 0); + s.pop(); + } + None => self.warn(format!("{}: can't read the placed PDF", asset.name)), + } + return; + } let Some(img) = self.load_image(g.asset) else { return }; let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return }; s.push_transform(&tf(g.xf)); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index b3e52cf..b925e65 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -17,6 +17,7 @@ designcraft-compose = { workspace = true } vello_cpu = { workspace = true } vello_common = { workspace = true } image = { workspace = true } +hayro = { workspace = true } log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5014c2f..03d44ec 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -592,6 +592,9 @@ pub fn blend_mode(b: DcBlend) -> BlendMode { /// Decode encoded image bytes into a premultiplied pixmap. pub fn decode_pixmap(bytes: &[u8]) -> Option { + if is_pdf(bytes) { + return render_pdf_page(bytes, 0, 3000); + } let img = image::load_from_memory(bytes).ok()?.to_rgba8(); let (w, h) = img.dimensions(); if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { @@ -644,11 +647,52 @@ fn now() -> u64 { /// Pixel size of an encoded image without decoding it fully. pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { + if is_pdf(bytes) { + // Placed PDFs are sized by their page (crop box) in points. + return pdf_page_size(bytes, 0).map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)); + } image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok() } /// MIME type guess for encoded image bytes. +/// Is this a PDF file (placed PDFs are graphics)? +pub fn is_pdf(bytes: &[u8]) -> bool { + bytes.starts_with(b"%PDF") +} + +/// Page count of a PDF. +pub fn pdf_page_count(bytes: &[u8]) -> Option { + let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + Some(pdf.pages().len()) +} + +/// Size of PDF page `page` (0-based), in points, as it shows (crop box, rotation applied). +pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { + let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let p = pdf.pages().get(page)?; + let (w, h) = p.render_dimensions(); + Some((w as f64, h as f64)) +} + +/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF +/// export embeds the page as vectors). +pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option { + let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let p = pdf.pages().get(page)?; + let (w, h) = p.render_dimensions(); + let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0); + let rs = hayro::RenderSettings { x_scale: scale, y_scale: scale, ..Default::default() }; + let pm = hayro::render(p, &hayro::RenderCache::new(), &hayro::hayro_interpret::InterpreterSettings::default(), &rs); + let (pw, ph) = (pm.width(), pm.height()); + let data: Vec = + pm.data_as_u8_slice().chunks_exact(4).map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect(); + Some(Pixmap::from_parts(data, pw, ph)) +} + pub fn image_mime(bytes: &[u8]) -> &'static str { + if is_pdf(bytes) { + return "application/pdf"; + } match image::guess_format(bytes) { Ok(image::ImageFormat::Png) => "image/png", Ok(image::ImageFormat::Jpeg) => "image/jpeg",