mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
2 files changed
+450
-5
No files matched your search
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user