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:?}"); + } +}