diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 3d2d6b3c8..c9d7e28dd 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -808,3 +808,46 @@ mod booklet_tests { assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); } } + +#[cfg(test)] +mod placed_pdf_print_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with + /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". + #[test] + fn pdf_17_placed_in_pdfx4() { + let logo = logo(); + assert!(logo.starts_with(b"%PDF-1.7")); + let mut s = page_with(&logo); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); + // PDF 2.0 can't simply be relabelled: the export fails and names the file. + let mut v20 = logo.clone(); + v20[5..8].copy_from_slice(b"2.0"); + let mut s = page_with(&v20); + let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); + assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); + } +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 244e54921..348a29f70 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -20,9 +20,10 @@ designcraft-images = { workspace = true } image = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +# The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it). +hayro-syntax = "0.7.2" [dev-dependencies] -hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-cmap = "0.1" kurbo = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 7e491646a..213d7676c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -148,6 +148,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) warnings, images: HashMap::new(), pdfs: HashMap::new(), + version, + too_new: Vec::new(), svgs: HashMap::new(), fonts: HashMap::new(), reverse_cmaps: HashMap::new(), @@ -200,6 +202,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) if opts.tagged { pdf.set_tag_tree(ex.tag_tree()); } + ex.check_placed_versions()?; let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; let mut warnings = ex.warnings; // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X. @@ -311,6 +314,9 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R warnings: Vec::new(), images: HashMap::new(), pdfs: HashMap::new(), + // krilla's default. + version: PdfVersion::Pdf17, + too_new: Vec::new(), svgs: HashMap::new(), fonts: HashMap::new(), reverse_cmaps: HashMap::new(), @@ -353,6 +359,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R s.finish(); page.finish(); } + ex.check_placed_versions()?; let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; let mut warnings = ex.warnings; warnings.dedup(); @@ -395,6 +402,10 @@ pub(crate) struct Exporter<'a> { images: HashMap>, /// Placed PDFs (embedded as vector pages). pdfs: HashMap>, + /// The PDF version written; placed PDFs must not be newer. + version: PdfVersion, + /// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails. + too_new: Vec, /// Parsed placed SVGs. svgs: HashMap>>, pub fonts: HashMap>, @@ -1088,6 +1099,58 @@ impl Exporter<'_> { } } + /// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the + /// export fails at the end with `VersionMismatch(Pdf17)`), and PDF/X-4 is PDF 1.6 while + /// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7 + /// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer + /// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]). + fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option { + use hayro_syntax::PdfVersion as V; + let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else { + self.warn(format!("{}: can't read the placed PDF", asset.name)); + return None; + }; + let max = match self.version { + PdfVersion::Pdf14 => V::Pdf14, + PdfVersion::Pdf15 => V::Pdf15, + PdfVersion::Pdf16 => V::Pdf16, + PdfVersion::Pdf17 => V::Pdf17, + PdfVersion::Pdf20 => V::Pdf20, + }; + let have = pdf.version(); + if have <= max { + return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf))); + } + if have == V::Pdf17 + && max == V::Pdf16 + && let Some(bytes) = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6") + && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(bytes)) + && pdf.version() <= max + { + self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name)); + return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf))); + } + let v = match have { + V::Pdf17 => "PDF 1.7", + V::Pdf20 => "PDF 2.0", + _ => "a newer PDF", + }; + self.too_new.push(format!("{} is {v}", asset.name)); + None + } + + /// Fail with the names of placed PDFs too new for this export (see [`Exporter::placed_pdf`]). + fn check_placed_versions(&self) -> Result<()> { + if self.too_new.is_empty() { + return Ok(()); + } + let v = self.version.as_str(); + Err(PdfError::Write(format!( + "a placed PDF is newer than the {v} this export writes ({}): save it as {v} or older and place it again", + self.too_new.join(", ") + ))) + } + fn load_image(&mut self, id: AssetId) -> Option { if let Some(i) = self.images.get(&id) { return i.clone(); @@ -1132,22 +1195,23 @@ impl Exporter<'_> { return; } // Placed PDFs go in as vectors (the page as a form XObject). - if let Some(asset) = self.doc.assets.get(&g.asset) + let doc = self.doc; + if let Some(asset) = 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, asset.page as usize); - s.pop(); + let placed = match self.pdfs.get(&g.asset) { + Some(p) => p.clone(), + None => { + let p = self.placed_pdf(asset); + self.pdfs.insert(g.asset, p.clone()); + p } - None => self.warn(format!("{}: can't read the placed PDF", asset.name)), + }; + if let Some(placed) = placed { + s.push_transform(&tf(g.xf)); + s.draw_pdf_page(&placed, size, asset.page as usize); + s.pop(); } return; } @@ -1178,6 +1242,19 @@ impl Exporter<'_> { } } +/// `data` with its `%PDF-x.y` header (within the first 1024 bytes, where readers look for it) +/// changed from `from` to `to`, both the same length. `None` when the header isn't `from`. +fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { + if from.len() != to.len() { + return None; + } + let head = data.get(..data.len().min(1024))?; + let at = head.windows(from.len()).position(|w| w == from)?; + let mut out = data.to_vec(); + out.get_mut(at..at + to.len())?.copy_from_slice(to); + Some(out) +} + fn lossless(data: &Arc>, fmt: Option) -> Option { let direct = match fmt { Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(),