Merge pull request #58 from EthanSuquet/feat/pdfx4-flag-rgb-under-cmyk-intent

PDF/X-4: flag device RGB under the CMYK output intent
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-07 22:04:21 -04:00
commit 625e6b0e01
2 files changed
+450 -5

No files matched your search

+2 -4
View File
@@ -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);
+448 -1
View File
@@ -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<Vec<std::ops::Range<usize>>> {
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<String> {
let mut issues = Vec::new();
let text = String::from_utf8_lossy(pdf);
@@ -217,5 +224,445 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
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<String> {
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<usize>)> = 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::<Dict<'_>>(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<String> = 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::<Dict<'_>>(xref.root_id()) else { return false };
let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents") else { return false };
intents.iter::<Dict<'_>>().any(|oi| {
oi.get::<Name<'_>>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")
&& oi.get::<Stream<'_>>(b"DestOutputProfile").and_then(|p| p.dict().get::<i32>(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::<Object<'_>>();
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<String>,
/// Forms, images and patterns already looked at on this page.
seen: HashSet<ObjectIdentifier>,
}
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::<Dict<'_>>(b"SMask"));
if let Some(form) = mask.and_then(|m| m.get::<Stream<'_>>(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::<Name<'_>>(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::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(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::<bool>(b"ImageMask") == Some(true) {
return;
}
if img.dict().get::<Object<'_>>(b"ColorSpace").is_some_and(|cs| is_device_rgb(&cs)) {
let (w, h) = (img.dict().get::<i32>(b"Width").unwrap_or(0), img.dict().get::<i32>(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::<Dict<'_>>(b"Resources").unwrap_or_default(), parent.clone());
if let Some(group) = form.dict().get::<Dict<'_>>(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::<Object<'_>>(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::<Object<'_>>(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::<Object<'_>>(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<u8>, 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<u8>>) -> Vec<u8> {
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<u8> {
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<u8>);
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<u8> {
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<String> {
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
let mut out: Vec<String> = pdf
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name>(b"Subtype").is_some_and(|n| n.as_str() == "Image") && s.dict().get::<bool>(b"ImageMask").is_none())
.filter_map(|s| match s.dict().get::<Object>(b"ColorSpace")? {
Object::Name(n) => Some(n.as_str().to_string()),
Object::Array(a) => a.iter::<Name>().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<u8> {
let objects = [
"<</Type/Catalog/Pages 2 0 R/OutputIntents[<</Type/OutputIntent/S/GTS_PDFX/DestOutputProfile 4 0 R>>]>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
format!("<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]{entries}/Contents 5 0 R>>"),
"<</N 4/Length 0>>\nstream\n\nendstream".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</N 3/Length 0>>\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<</Size {}/Root 1 0 R>>\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<</ColorSpace<</DefaultRGB[/ICCBased 6 0 R]>>>>", 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<</S/Transparency/CS/DeviceRGB>>", "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:?}");
}
}