diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..ef655b500 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -850,6 +850,45 @@ mod placed_pdf_print_tests { 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}"); } + + /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. + fn catalog_v17(header: &str) -> Vec { + let content = "0 0 0 1 k 0 0 200 200 re f"; + let mut pdf = format!("{header}\n").into_bytes(); + let mut offsets = Vec::new(); + for body in [ + "<>".to_owned(), + "<>".to_owned(), + "<>>>".to_owned(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + pdf + } + + /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export + /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). + #[test] + fn catalog_version_17_placed_in_pdfx4() { + for header in ["%PDF-1.7", "%PDF-1.4"] { + let pdf = catalog_v17(header); + assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); + let mut s = page_with(&pdf); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + let out = base64_decode(r["base64"].as_str().unwrap()); + assert!(out.starts_with(b"%PDF-1.6")); + assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); + } + } } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..45b87e6f5 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1172,7 +1172,8 @@ 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 goes in as 1.6 (its header and catalog `/Version` 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; @@ -1193,8 +1194,8 @@ impl Exporter<'_> { } 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)) + && let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec()) + && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(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)); @@ -1360,6 +1361,30 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { Some(out) } +/// `data` with every `/Version /1.7` changed to `/Version /1.6` in place, so the xref offsets +/// hold. The catalog's `/Version` wins over the header (TCPDF writes both), so relabelling the +/// header alone leaves the file a PDF 1.7. A catalog inside a compressed object stream is left as +/// it is, and the version check still names the file. +fn relabel_catalog_version(mut data: Vec) -> Vec { + const KEY: &[u8] = b"/Version"; + let mut from = 0; + while let Some(at) = data.get(from..).and_then(|rest| rest.windows(KEY.len()).position(|w| w == KEY)) { + let mut i = from + at + KEY.len(); + while data.get(i).is_some_and(|b| b" \t\r\n\x0c\0".contains(b)) { + i += 1; + } + let ends = data.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'.'); + if data.get(i..i + 4) == Some(b"/1.7".as_slice()) + && ends + && let Some(b) = data.get_mut(i + 3) + { + *b = b'6'; + } + from = i; + } + data +} + /// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4. pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc");