From 873d2930178e8eebcbb429d15edce329febb6971 Mon Sep 17 00:00:00 2001 From: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:13:06 -0400 Subject: [PATCH] PDF: PDF/X-4 flags device RGB under its CMYK output intent check_pdfx4 didn't look at colour spaces, so a placed RGB PNG (or a CMYK TIFF converted to RGB, #35) went into a PDF/X-4 file as DeviceRGB under the CMYK output intent with an empty warnings list. PDF/X-4 lets RGB in only when it is colour-managed: ICCBased, or DeviceRGB remapped by a DefaultRGB colour space. DeviceCMYK and DeviceGray are the intent's own values. The check now reads the file with hayro-syntax and, when the PDF/X output intent's profile has four channels, walks each page: its content, the images, forms (placed PDFs), patterns, shadings and soft masks it uses, and the transparency groups. It reports DeviceRGB (or Indexed over it) images with their object number and pixel size, RGB fills and strokes, RGB gradients, RGB inline images and groups blending in RGB, each with the pages it is on, unless a DefaultRGB is in scope. Like the other PDF/X findings these are export warnings. The separate "some transparency groups still blend in RGB" warning is gone: the group check covers it, with the page. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 6 +- crates/pdf/src/pdfx.rs | 449 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 450 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a4e60c3..d98f7a4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -207,11 +207,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) // Transparency blends in the document's blend space: CMYK for print (and always in PDF/X). let mut warnings = ex.warnings; if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) { + // The rewrite matches krilla's exact output; in PDF/X-4, `check_pdfx4` reports any + // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); - // The rewrite matches krilla's exact output; say so if a group got past it. - if opts.standard == Standard::PdfX4 && crate::pdfx::has_rgb_groups(&bytes) { - warnings.push("PDF/X-4: some transparency groups still blend in RGB".into()); - } } // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X. let fields = crate::forms::collect(doc, &sheets); diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index df05552..ad3a140 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -2,6 +2,12 @@ //! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a //! check of the requirements this exporter is responsible for. +use std::collections::HashSet; + +use hayro_syntax::content::UntypedIter; +use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream}; +use hayro_syntax::page::Resources; + use crate::transitions::{find, int_after, object, rfind}; /// Escape a PDF literal string. @@ -187,7 +193,8 @@ fn dictionary_ranges(pdf: &[u8]) -> Option>> { Some(out) } -/// PDF/X-4 checks on an exported file: what's missing (empty = passes these checks). +/// PDF/X-4 checks on an exported file: what's missing or not allowed, device RGB under the CMYK +/// output intent included (empty = passes these checks). pub fn check_pdfx4(pdf: &[u8]) -> Vec { let mut issues = Vec::new(); let text = String::from_utf8_lossy(pdf); @@ -217,5 +224,445 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { if text.contains("/Subtype/Type3") { issues.push("Type 3 fonts aren't allowed".into()); } + issues.extend(device_rgb(pdf)); issues } + +/// Device-dependent RGB under a CMYK output intent. PDF/X-4 (ISO 15930-7) lets RGB in only when +/// it is colour-managed: ICCBased RGB, or DeviceRGB remapped by a `DefaultRGB` colour space in the +/// resources. DeviceCMYK and DeviceGray are the output intent's own device values, so they stay. +/// This walks every page's content, the images, forms, patterns, shadings and soft masks it uses, +/// and the transparency groups, and reports each kind of device RGB it finds with the pages it is +/// on. Nothing is reported when the file has no CMYK PDF/X output intent. +fn device_rgb(pdf: &[u8]) -> Vec { + let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { + return vec!["the file couldn't be read to check its colour spaces".into()]; + }; + if !cmyk_output_intent(&file) { + return Vec::new(); + } + let mut found: Vec<(String, Vec)> = Vec::new(); + for (i, page) in file.pages().iter().enumerate() { + let mut scan = RgbScan::default(); + let res = page.resources(); + if let Some(group) = page.raw().get::>(b"Group") { + scan.group(&group, res); + } + if let Some(content) = page.page_stream() { + scan.content(content, res, 0); + } + for what in scan.found { + match found.iter_mut().find(|(w, _)| *w == what) { + Some((_, pages)) => pages.push(i + 1), + None => found.push((what, vec![i + 1])), + } + } + } + found + .into_iter() + .map(|(what, pages)| { + let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let more = if pages.len() > 10 { ", …" } else { "" }; + let s = if pages.len() == 1 { "" } else { "s" }; + format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", ")) + }) + .collect() +} + +/// Whether the file's PDF/X output intent has a four-channel (CMYK) destination profile. +fn cmyk_output_intent(file: &hayro_syntax::Pdf) -> bool { + let xref = file.xref(); + let Some(catalog) = xref.get::>(xref.root_id()) else { return false }; + let Some(intents) = catalog.get::>(b"OutputIntents") else { return false }; + intents.iter::>().any(|oi| { + oi.get::>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX") + && oi.get::>(b"DestOutputProfile").and_then(|p| p.dict().get::(b"N")) == Some(4) + }) +} + +/// Whether a colour space is device RGB: `/DeviceRGB` (`/RGB` in inline images), or an +/// `/Indexed` space over it. +fn is_device_rgb(cs: &Object<'_>) -> bool { + match cs { + Object::Name(n) => matches!(n.as_ref(), b"DeviceRGB" | b"RGB"), + Object::Array(a) => { + let mut items = a.iter::>(); + match items.next() { + Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I") => { + // The base space is a name or a (non-indexed) array; no deeper nesting. + matches!(items.next(), Some(Object::Name(b)) if matches!(b.as_ref(), b"DeviceRGB" | b"RGB")) + } + _ => false, + } + } + _ => false, + } +} + +/// How deep forms, patterns and soft masks may nest before the walk stops. +const MAX_DEPTH: usize = 16; + +/// What one page uses in device RGB, in the order found. +#[derive(Default)] +struct RgbScan { + found: Vec, + /// Forms, images and patterns already looked at on this page. + seen: HashSet, +} + +impl RgbScan { + fn add(&mut self, what: String) { + if !self.found.contains(&what) { + self.found.push(what); + } + } + + /// `DefaultRGB` in scope makes DeviceRGB colour-managed. + fn managed(res: &Resources<'_>) -> bool { + res.get_color_space(&Name::new_unescaped(b"DefaultRGB")).is_some() + } + + /// A content stream (a page's or a form's) with its resources. + fn content(&mut self, data: &[u8], res: &Resources<'_>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let managed = Self::managed(res); + let mut ops = UntypedIter::new(data); + while let Some(op) = ops.next() { + let operator: &[u8] = op.operator; + let last = op.operands().last().cloned(); + let name = || last.clone().and_then(Object::into_name); + match operator { + b"rg" | b"RG" if !managed => self.add("RGB fills or strokes".into()), + b"cs" | b"CS" if !managed => { + let rgb = name().is_some_and(|n| n.as_ref() == b"DeviceRGB" || res.get_color_space(&n).is_some_and(|cs| is_device_rgb(&cs))); + if rgb { + self.add("RGB fills or strokes".into()); + } + } + b"sh" => { + if let Some(sh) = name().and_then(|n| res.get_shading(&n)) { + self.shading(sh, managed); + } + } + b"scn" | b"SCN" => { + if let Some(p) = name().and_then(|n| res.get_pattern(&n)) { + self.pattern(p, res, managed, depth); + } + } + b"gs" => { + let mask = name().and_then(|n| res.get_ext_g_state(&n)).and_then(|gs| gs.get::>(b"SMask")); + if let Some(form) = mask.and_then(|m| m.get::>(b"G")) { + self.form(&form, res, depth); + } + } + b"Do" => { + if let Some(xo) = name().and_then(|n| res.get_x_object(&n)) { + match xo.dict().get::>(b"Subtype").as_deref() { + Some(b"Image") => self.image(&xo, managed), + Some(b"Form") => self.form(&xo, res, depth), + _ => {} + } + } + } + b"BI" | b"EI" if !managed => { + if let Some(Object::Stream(img)) = last { + let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); + let cs = match cs { + Some(Object::Name(n)) + if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => + { + res.get_color_space(&n) + } + cs => cs, + }; + if cs.is_some_and(|cs| is_device_rgb(&cs)) { + self.add("an RGB inline image".into()); + } + } + } + _ => {} + } + } + } + + fn image(&mut self, img: &Stream<'_>, managed: bool) { + let id = img.obj_id(); + if managed || !self.seen.insert(id) || img.dict().get::(b"ImageMask") == Some(true) { + return; + } + if img.dict().get::>(b"ColorSpace").is_some_and(|cs| is_device_rgb(&cs)) { + let (w, h) = (img.dict().get::(b"Width").unwrap_or(0), img.dict().get::(b"Height").unwrap_or(0)); + self.add(format!("an RGB image (object {}, {w} × {h} px)", id.obj_number)); + } + } + + /// A form XObject (placed PDF page, group, soft mask): its group, then its content. + fn form(&mut self, form: &Stream<'_>, parent: &Resources<'_>, depth: usize) { + if depth >= MAX_DEPTH || !self.seen.insert(form.obj_id()) { + return; + } + let res = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), parent.clone()); + if let Some(group) = form.dict().get::>(b"Group") { + self.group(&group, &res); + } + if let Ok(data) = form.decoded() { + self.content(&data, &res, depth + 1); + } + } + + fn group(&mut self, group: &Dict<'_>, res: &Resources<'_>) { + if !Self::managed(res) && group.get::>(b"CS").is_some_and(|cs| is_device_rgb(&cs)) { + self.add("a transparency group blending in RGB".into()); + } + } + + /// A shading dictionary or stream (gradients). + fn shading(&mut self, sh: Object<'_>, managed: bool) { + let dict = match sh { + Object::Dict(d) => d, + Object::Stream(s) => s.dict().clone(), + _ => return, + }; + if !managed && dict.get::>(b"ColorSpace").is_some_and(|cs| is_device_rgb(&cs)) { + self.add("RGB gradients".into()); + } + } + + /// A shading pattern (its shading) or a tiling pattern (its cell, like a form). + fn pattern(&mut self, p: Object<'_>, res: &Resources<'_>, managed: bool, depth: usize) { + match p { + Object::Dict(d) => { + if let Some(sh) = d.get::>(b"Shading") { + self.shading(sh, managed); + } + } + Object::Stream(s) => self.form(&s, res, depth), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use designcraft_color::{Color, Swatch}; + use designcraft_compose::Cache; + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Fitting, Graphic, Item, ItemId, Shape, SpreadRef}; + use designcraft_geom::{Affine, Rect}; + use hayro_syntax::object::{Name, Object}; + use image::{ExtendedColorType, ImageEncoder}; + + use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report}; + + fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) { + let asset = AssetId(d.alloc()); + let mime = designcraft_images::mime(&data).into(); + let pixels = designcraft_images::pixel_size(&data); + d.assets.insert(asset, Arc::new(Asset { id: asset, name: name.into(), mime, link: None, data: data.into(), pixels, page: 0 })); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0); + let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.content = Content::Graphic(Graphic { + asset, + size, + xf: Affine::translate(at) * Affine::scale(100.0 / size.0), + auto_fit: Fitting::FillProportionally, + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + + fn add_box(d: &mut Document, r: Rect, swatch: &str) { + let lid = d.default_layer(); + let mut b = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + b.fill = Fill::swatch(swatch); + d.insert_item(SpreadRef::Doc(0), b, None).unwrap(); + } + + /// A 4 × 2 PNG: red RGB or mid grey, optionally carrying an ICC profile. + fn png(gray: bool, icc: Option>) -> Vec { + let mut b = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut b); + if let Some(p) = icc { + enc.set_icc_profile(p).unwrap(); + } + let (px, ty) = if gray { (vec![128u8; 8], ExtendedColorType::L8) } else { ([255u8, 0, 0].repeat(8), ExtendedColorType::Rgb8) }; + enc.write_image(&px, 4, 2, ty).unwrap(); + b + } + + /// The header of an ICC v2.1 RGB display profile: what a PDF writer reads to tag an image + /// `/ICCBased` (the check looks at the colour space, not at the profile's tables). + fn rgb_icc() -> Vec { + let mut p = vec![0u8; 132]; + p[..4].copy_from_slice(&132u32.to_be_bytes()); + p[8] = 2; + p[9] = 0x10; + p[12..16].copy_from_slice(b"mntr"); + p[16..20].copy_from_slice(b"RGB "); + p[20..24].copy_from_slice(b"XYZ "); + p[36..40].copy_from_slice(b"acsp"); + p + } + + /// A 4 × 2 CMYK image (100% K) as krilla writes it. + #[derive(Clone, Hash)] + struct CmykImage(Vec); + + impl krilla::image::CustomImage for CmykImage { + fn color_channel(&self) -> &[u8] { + &self.0 + } + fn alpha_channel(&self) -> Option<&[u8]> { + None + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + (4, 2) + } + fn icc_profile(&self) -> Option<&[u8]> { + None + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Cmyk + } + } + + /// A one-page PDF 1.6 holding a CMYK image, as another app would hand over a placed graphic. + fn pdf_with_cmyk_image() -> Vec { + let configuration = krilla::configure::ConfigurationBuilder::new().with_version(krilla::configure::PdfVersion::Pdf16).finish().unwrap(); + let mut pdf = krilla::Document::new_with(krilla::SerializeSettings { configuration, ..Default::default() }); + let size = krilla::geom::Size::from_wh(100.0, 50.0).unwrap(); + let mut page = pdf.start_page_with(krilla::page::PageSettings::new(size)); + let mut s = page.surface(); + let img = krilla::image::Image::from_custom(CmykImage([0u8, 0, 0, 255].repeat(8)), false).unwrap(); + s.draw_image(img, size); + s.finish(); + page.finish(); + pdf.finish().unwrap() + } + + fn x4(d: &Document) -> ExportReport { + export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap() + } + + /// The `/ColorSpace` of every image XObject: a name, or `[` for an array (ICCBased, Indexed…). + fn image_spaces(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + let mut out: Vec = pdf + .objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::(b"Subtype").is_some_and(|n| n.as_str() == "Image") && s.dict().get::(b"ImageMask").is_none()) + .filter_map(|s| match s.dict().get::(b"ColorSpace")? { + Object::Name(n) => Some(n.as_str().to_string()), + Object::Array(a) => a.iter::().next().map(|n| format!("[{}", n.as_str())), + _ => None, + }) + .collect(); + out.sort(); + out + } + + /// A placed RGB PNG went into PDF/X-4 as DeviceRGB under the CMYK output intent with no + /// warning (#35's fallback). + #[test] + fn device_rgb_image_is_flagged() { + let mut d = Document::new(&NewDocument::default()); + place(&mut d, "photo.png", png(false, None), (4.0, 2.0), (72.0, 72.0)); + let r = x4(&d); + assert_eq!(image_spaces(&r.bytes), ["DeviceRGB"]); + let rgb: Vec<&String> = r.warnings.iter().filter(|w| w.contains("RGB")).collect(); + assert_eq!(rgb.len(), 1, "{:?}", r.warnings); + assert!(rgb[0].starts_with("PDF/X-4: an RGB image (object ") && rgb[0].contains("4 × 2 px) on page 1:"), "{}", rgb[0]); + // Without a PDF/X output intent there is nothing to check RGB against. + let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(!check_pdfx4(&plain.bytes).iter().any(|i| i.contains("RGB"))); + } + + /// CMYK and grey are the output intent's own values and ICC-tagged RGB is colour-managed: + /// none of them is flagged, whether placed directly or inside a placed PDF. + #[test] + fn cmyk_grey_and_icc_rgb_pass() { + let mut d = Document::new(&NewDocument::default()); + add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "C=100 M=0 Y=0 K=0"); + place(&mut d, "grey.png", png(true, None), (4.0, 2.0), (72.0, 72.0)); + place(&mut d, "tagged.png", png(false, Some(rgb_icc())), (4.0, 2.0), (72.0, 172.0)); + place(&mut d, "logo.pdf", pdf_with_cmyk_image(), (100.0, 50.0), (72.0, 272.0)); + let r = x4(&d); + assert_eq!(image_spaces(&r.bytes), ["DeviceCMYK", "DeviceGray", "[ICCBased"]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + + /// RGB swatches paint in DeviceRGB. + #[test] + fn device_rgb_fills_are_flagged() { + let mut d = Document::new(&NewDocument { pages: 2, ..NewDocument::default() }); + d.swatches.push(Swatch::color("Brand Red", Color::rgb(0.9, 0.1, 0.1))); + add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "Brand Red"); + let r = x4(&d); + assert_eq!(r.warnings, ["PDF/X-4: RGB fills or strokes on page 1: under a CMYK output intent RGB needs an ICC profile"]); + } + + /// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its + /// resources, a group…) and `content`. + fn tiny_pdf(entries: &str, content: &str) -> Vec { + let objects = [ + "<>]>>".to_string(), + "<>".to_string(), + format!("<>"), + "<>\nstream\n\nendstream".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>\nstream\n\nendstream".to_string(), + ]; + let mut out = b"%PDF-1.6\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out + } + + /// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed. + #[test] + fn default_rgb_makes_device_rgb_managed() { + let rgb = |i: &String| i.contains("RGB"); + let content = "1 0 0 rg 0 0 10 10 re f /DeviceRGB CS 0 0 1 SC 0 0 m 10 10 l S"; + let bare = check_pdfx4(&tiny_pdf("/Resources<<>>", content)); + assert_eq!(bare.iter().filter(|i| rgb(i)).count(), 1, "{bare:?}"); + let managed = check_pdfx4(&tiny_pdf("/Resources<>>>", content)); + assert!(!managed.iter().any(rgb), "{managed:?}"); + // CMYK and grey need no default space. + let cmyk = check_pdfx4(&tiny_pdf("/Resources<<>>", "0 0 0 1 k 0 0 10 10 re f 0.5 G 0 0 m 10 10 l S")); + assert!(!cmyk.iter().any(rgb), "{cmyk:?}"); + } + + /// An RGB blending space on the page group and an RGB inline image are device RGB too. + #[test] + fn rgb_groups_and_inline_images_are_flagged() { + let group = check_pdfx4(&tiny_pdf("/Resources<<>>/Group<>", "0 0 0 1 k")); + assert!( + group.iter().any(|i| i == "a transparency group blending in RGB on page 1: under a CMYK output intent RGB needs an ICC profile"), + "{group:?}" + ); + let inline = check_pdfx4(&tiny_pdf("/Resources<<>>", "q BI /W 1 /H 1 /CS /RGB /BPC 8 /F /AHx ID ff0000> EI Q")); + assert!(inline.iter().any(|i| i.starts_with("an RGB inline image on page 1")), "{inline:?}"); + let grey = check_pdfx4(&tiny_pdf("/Resources<<>>", "q BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 80> EI Q")); + assert!(!grey.iter().any(|i| i.contains("RGB")), "{grey:?}"); + } +}