H: place PDF — sized by crop box, hayro on screen, vector form XObjects in PDF export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:11:04 -04:00
parent 446c70af04
commit da0e87b86b
9 changed files with 222 additions and 4 deletions

101
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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";

View File

@ -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"])

View File

@ -114,6 +114,10 @@ fn list(d: &Document) -> Vec<Value> {
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();

View File

@ -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);
}
}

View File

@ -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<String>,
images: HashMap<AssetId, Option<Image>>,
/// Placed PDFs (embedded as vector pages).
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
pub fonts: HashMap<u32, Option<krilla::text::Font>>,
pub reverse_cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
/// 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));

View File

@ -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]

View File

@ -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<Pixmap> {
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<usize> {
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<Pixmap> {
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<vello_cpu::color::PremulRgba8> =
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",