PDF: a placed PDF 1.7 with /Version /1.7 in its catalog goes into PDF/X-4 exports (fixes #907)

This commit is contained in:
Marco Arnulfo
2026-10-14 16:48:52 +02:00
parent 11a51d5b66
commit 52eac14f2c
2 changed files with 67 additions and 3 deletions
+39
View File
@@ -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<u8> {
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 [
"<</Type/Catalog/Pages 2 0 R/Version /1.7>>".to_owned(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_owned(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]/Contents 4 0 R/Resources<<>>>>".to_owned(),
format!("<</Length {}>>\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<</Size 5/Root 1 0 R>>\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)]
+28 -3
View File
@@ -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<krilla::pdf::PdfDocument> {
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<Vec<u8>> {
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<u8>) -> Vec<u8> {
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");