PDF/X-4: RGB images carry sRGB, no image interpolation, pdf:Trapped in XMP (#223)

- An RGB image with no ICC profile of its own was written as DeviceRGB
  under the CMYK output intent, and the export only warned. In PDF/X-4
  it now carries the sRGB profile (assets/icc/sRGB-v2-magic.icc, ICC
  v2.1, CC0). JPEGs stay passed through. Untagged PNG, GIF and WebP,
  and the decoded TIFF, BMP and PSD fallback, go in as RGB with the
  profile. A re-encoded JPEG keeps the source's RGB profile or gets
  sRGB. Grey images and images with their own profile are unchanged.
- Every image constructor asked for interpolation. PDF/X-4 and PDF/A
  exports now write images without it, which also removes the
  ImageInterpolation error that stopped PDF/A exports with a placed
  image. Plain exports keep it.
- make_pdfx4 writes pdf:Trapped False into the XMP, matching
  /Trapped/False in the document information.
- check_pdfx4 reports an image that asks for interpolation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 02:22:52 -04:00
1 parent e161878e4c
commit 464a442802
6 files changed
+262 -22

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `crates/ui-egui/src/i18n/uk.rs` (15 common phrases) | DeckCraft contributors | https://github.com/storytold/deckcraft/blob/3a45c614a409349fc13d449f35bd98f3321f0df5/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary; validation dictionaries are external and are not bundled |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `https://www.gutenberg.org/files/3204/files/mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`) | Public Domain ("Public Domain material by grant from the author, January, 2001"; Project Gutenberg: "Public domain in the USA") | ~165k words with break points, front-coded + deflate; regenerate with `cargo run --release -p designcraft-compose --example hyphgen -- mhyph.txt assets/hyphenation` |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from `en-us.dic`'s source list by `crates/compose/src/hyphen/patgen.rs` (`examples/hyphgen.rs`) | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX/hyph-utf8 patterns used) |
| `assets/icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/saucecontrol/Compact-ICC-Profiles, `profiles/sRGB-v2-magic.icc` at `abb1b6f48570b4eb0fabd8566e517dca07e617d4`, SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | Public Domain (CC0-1.0) | ICC v2.1 sRGB profile, 736 bytes. PDF/X-4 export tags RGB images that have no profile of their own with it |
| `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
| `assets/fonts/Inter-Regular.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
| `assets/fonts/Inter-SemiBold.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
+1 -1
View File
@@ -109,7 +109,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
Binary file not shown.
+123 -14
View File
@@ -1192,22 +1192,24 @@ impl Exporter<'_> {
let asset = self.doc.assets.get(&id)?.clone();
let data = asset.data.clone();
let fmt = image::guess_format(&data).ok();
let mut mode = RasterMode::for_standard(self.opts.standard);
mode.interpolate &= self.interpolate;
let img = match fmt {
Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(),
Some(image::ImageFormat::Jpeg) => jpeg(data.clone().into(), || embedded_icc(&data), mode),
// A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print
// as four-colour black). PDF/A exports are RGB only.
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) {
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, mode) {
Some(img) => Some(img),
None => {
self.warn(format!(
"{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)",
asset.name
));
lossless(&data, fmt, self.interpolate)
lossless(&data, fmt, mode)
}
},
_ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)),
_ => lossless(&data, fmt, self.interpolate),
_ if self.opts.compress_images => recompress(&data, mode).or_else(|| lossless(&data, fmt, mode)),
_ => lossless(&data, fmt, mode),
};
if img.is_none() {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
@@ -1301,24 +1303,130 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
Some(out)
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, interpolate: bool) -> Option<Image> {
/// 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");
/// How raster images are written for the export's standard.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RasterMode {
/// `/Interpolate true`: smoother upscaling in viewers. PDF/X-4 and PDF/A don't allow it.
interpolate: bool,
/// Give RGB images with no ICC profile of their own the sRGB profile: under PDF/X-4's CMYK
/// output intent, RGB must be colour-managed.
tag_rgb: bool,
}
impl RasterMode {
fn for_standard(standard: Standard) -> Self {
Self { interpolate: standard == Standard::None, tag_rgb: standard == Standard::PdfX4 }
}
}
/// The ICC profile embedded in an image file, if it has one.
fn embedded_icc(data: &[u8]) -> Option<Vec<u8>> {
use image::ImageDecoder;
let mut dec = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_decoder().ok()?;
dec.icc_profile().ok().flatten()
}
/// Whether an ICC profile describes RGB (its colour space signature, header bytes 16 to 20).
fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
/// without a profile stays DeviceGray or DeviceCMYK.
fn jpeg(data: krilla::Data, icc: impl FnOnce() -> Option<Vec<u8>>, mode: RasterMode) -> Option<Image> {
if !mode.tag_rgb {
return Image::from_jpeg(data, mode.interpolate).ok();
}
let icc: krilla::Data = match icc() {
Some(own) => own.into(),
None => SRGB_ICC.into(),
};
Image::from_jpeg_with_icc(data, Some(icc), mode.interpolate).ok()
}
/// Whether krilla would write this PNG, GIF or WebP as untagged RGB: not grey, and no profile of
/// its own.
fn untagged_rgb(data: &[u8]) -> bool {
use image::ImageDecoder;
let Some(mut dec) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok().and_then(|r| r.into_decoder().ok()) else {
return false;
};
let grey = matches!(dec.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16);
!grey && dec.icc_profile().ok().flatten().is_none()
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, mode: RasterMode) -> Option<Image> {
let i = mode.interpolate;
// krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole
// export when the PDF is written. Decoding it first means it is skipped, like any other
// image that can't be decoded.
let rgba = designcraft_images::decode_rgba(data)?;
let direct = match fmt {
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(),
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(),
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(),
Some(image::ImageFormat::Png | image::ImageFormat::Gif | image::ImageFormat::WebP) if mode.tag_rgb && untagged_rgb(data) => None,
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), i).ok(),
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), i).ok(),
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), i).ok(),
_ => None,
};
direct.or_else(|| {
// TIFF, BMP, PSD composites…
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
if mode.tag_rgb {
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
}
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
}
/// An RGB raster tagged with the sRGB profile, for PDF/X-4.
#[derive(Clone, Hash)]
struct SrgbImage {
rgb: Arc<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
}
impl SrgbImage {
fn new(rgba: image::RgbaImage) -> Option<Self> {
let size = rgba.dimensions();
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
let mut rgb = Vec::with_capacity(pixels.checked_mul(3)?);
let mut alpha = Vec::with_capacity(pixels);
for p in rgba.pixels() {
rgb.extend_from_slice(&p.0[..3]);
alpha.push(p.0[3]);
}
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
Some(Self { rgb: Arc::new(rgb), alpha, size })
}
}
impl krilla::image::CustomImage for SrgbImage {
fn color_channel(&self) -> &[u8] {
&self.rgb
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.alpha.as_deref().map(Vec::as_slice)
}
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
krilla::image::BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
self.size
}
fn icc_profile(&self) -> Option<&[u8]> {
Some(SRGB_ICC)
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Rgb
}
}
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
@@ -1348,18 +1456,19 @@ impl krilla::image::CustomImage for CmykImage {
}
/// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK.
fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option<Image> {
fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option<Image> {
let r = designcraft_images::decode_cmyk_tiff(data)?;
// krilla panics when the channel lengths don't match the size.
let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?;
if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) {
return None;
}
Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok()
Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok()
}
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
fn recompress(data: &[u8], interpolate: bool) -> Option<Image> {
/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
let img = image::load_from_memory(data).ok()?;
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
@@ -1368,5 +1477,5 @@ fn recompress(data: &[u8], interpolate: bool) -> Option<Image> {
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
Image::from_jpeg(buf.into(), interpolate).ok()
jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
}
+3 -1
View File
@@ -15,7 +15,9 @@
//! Gradients with mixed output colour spaces are converted to RGB with an export warning;
//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion).
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]).
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). PDF/X-4 and PDF/A
//! images are written without interpolation. In PDF/X-4 an RGB image without a profile of its own
//! carries sRGB (`assets/icc/sRGB-v2-magic.icc`).
//! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a
//! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the
//! right characters), so text is selectable and searchable.
+134 -6
View File
@@ -84,7 +84,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option<Vec<u8>> {
i
}
};
// XMP: the PDF/X identification schema.
// XMP: the PDF/X identification schema, and the trapping state that Info also states.
if let Some(meta) = int_after(&cat, "/Metadata") {
let r = object(pdf, meta)?;
let body = &pdf[r];
@@ -96,7 +96,10 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option<Vec<u8>> {
if dict.contains("/Filter") {
return None;
}
let add = "<rdf:Description rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"><pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion></rdf:Description>";
let add = concat!(
"<rdf:Description rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"><pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion></rdf:Description>",
"<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Trapped>False</pdf:Trapped></rdf:Description>"
);
let xml = xml.replacen("</rdf:RDF>", &format!("{add}</rdf:RDF>"), 1);
let dict = {
let i = dict.find("/Length")?;
@@ -213,6 +216,9 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") {
issues.push("the XMP metadata doesn't name PDF/X-4".into());
}
if text.contains("/Interpolate true") {
issues.push("an image asks for interpolation".into());
}
if text.contains("/Encrypt") {
issues.push("the file is encrypted".into());
}
@@ -538,12 +544,16 @@ mod tests {
/// 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> {
pdf_with_image(krilla::image::Image::from_custom(CmykImage([0u8, 0, 0, 255].repeat(8)), false).unwrap())
}
/// A one-page PDF 1.6 holding `img`.
fn pdf_with_image(img: krilla::image::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();
@@ -572,12 +582,13 @@ mod tests {
out
}
/// A placed RGB PNG went into PDF/X-4 as DeviceRGB under the CMYK output intent with no
/// warning (#35's fallback).
/// A DeviceRGB image under the CMYK output intent is reported. A placed PDF carries its
/// images into the export unchanged.
#[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 photo = krilla::image::Image::from_png(png(false, None).into(), false).unwrap();
place(&mut d, "photo.pdf", pdf_with_image(photo), (100.0, 50.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();
@@ -665,4 +676,121 @@ mod tests {
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:?}");
}
/// A 4 × 2 RGB image in every raster path the exporter has, none with a profile. Each has
/// its own colour, so no two share an image XObject.
fn rgb_rasters() -> Vec<(&'static str, Vec<u8>)> {
let px = |c: [u8; 3]| c.repeat(8);
let encode = |f: &dyn Fn(&mut Vec<u8>)| {
let mut b = Vec::new();
f(&mut b);
b
};
let jpeg = encode(&|b| image::codecs::jpeg::JpegEncoder::new(b).write_image(&px([0, 0, 255]), 4, 2, ExtendedColorType::Rgb8).unwrap());
let gif = encode(&|b| image::codecs::gif::GifEncoder::new(b).encode(&[0u8, 255, 0, 255].repeat(8), 4, 2, ExtendedColorType::Rgba8).unwrap());
let webp =
encode(&|b| image::codecs::webp::WebPEncoder::new_lossless(b).write_image(&px([255, 255, 0]), 4, 2, ExtendedColorType::Rgb8).unwrap());
let tiff = encode(&|b| {
image::codecs::tiff::TiffEncoder::new(std::io::Cursor::new(b)).write_image(&px([0, 255, 255]), 4, 2, ExtendedColorType::Rgb8).unwrap()
});
vec![("photo.png", png(false, None)), ("photo.jpg", jpeg), ("photo.gif", gif), ("photo.webp", webp), ("photo.tif", tiff)]
}
/// The decoded `/ICCBased` profile of every image XObject that has one.
fn image_profiles(bytes: &[u8]) -> Vec<Vec<u8>> {
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
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"))
.filter_map(|s| match s.dict().get::<hayro_syntax::object::Array>(b"ColorSpace")?.iter::<Object>().nth(1)? {
Object::Stream(p) => Some(p.decoded().ok()?.into_owned()),
_ => None,
})
.collect()
}
/// PDF/X-4 wrote placed RGB images as DeviceRGB under the CMYK output intent, and only warned.
/// Each raster path now tags them with sRGB, re-encoded as JPEG or not. A plain export keeps
/// DeviceRGB.
#[test]
fn rgb_images_carry_srgb_in_pdfx4() {
let mut d = Document::new(&NewDocument::default());
for (i, (name, data)) in rgb_rasters().into_iter().enumerate() {
place(&mut d, name, data, (4.0, 2.0), (72.0, 72.0 + 60.0 * i as f64));
}
for compress_images in [false, true] {
let opts = PdfOptions { standard: Standard::PdfX4, compress_images, ..Default::default() };
let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap();
assert!(r.warnings.is_empty(), "compress_images {compress_images}: {:?}", r.warnings);
assert_eq!(image_spaces(&r.bytes), ["[ICCBased"; 5], "compress_images {compress_images}");
let profiles = image_profiles(&r.bytes);
assert_eq!(profiles.len(), 5);
assert!(profiles.iter().all(|p| p == crate::export::SRGB_ICC), "every image carries the sRGB profile");
}
let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(image_spaces(&plain.bytes), ["DeviceRGB"; 5]);
}
/// An image's own profile and a grey image are left as they are in PDF/X-4.
#[test]
fn own_profiles_and_grey_stay() {
let mut d = Document::new(&NewDocument::default());
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));
let r = x4(&d);
assert_eq!(image_spaces(&r.bytes), ["DeviceGray", "[ICCBased"]);
assert_eq!(image_profiles(&r.bytes), [rgb_icc()]);
}
/// Every image constructor asked for interpolation, which PDF/X-4 and PDF/A forbid: PDF/A
/// exports with a placed image failed (#71). Plain exports keep it.
#[test]
fn no_interpolation_in_pdfx4_or_pdfa() {
let mut d = Document::new(&NewDocument::default());
for (i, (name, data)) in rgb_rasters().into_iter().enumerate() {
place(&mut d, name, data, (4.0, 2.0), (72.0, 72.0 + 60.0 * i as f64));
}
let interpolated = |b: &[u8]| String::from_utf8_lossy(b).matches("/Interpolate true").count();
for compress_images in [false, true] {
for standard in [Standard::PdfX4, Standard::PdfA2b] {
let opts = PdfOptions { standard, compress_images, ..Default::default() };
let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap_or_else(|e| panic!("{standard:?}: {e:?}"));
assert_eq!(interpolated(&r.bytes), 0, "{standard:?}, compress_images {compress_images}");
}
let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap();
// The TIFF goes through the decoded fallback, which never asked for interpolation,
// unless it is re-encoded as JPEG.
assert_eq!(interpolated(&plain.bytes), if compress_images { 5 } else { 4 }, "compress_images {compress_images}");
assert!(check_pdfx4(&plain.bytes).iter().any(|i| i == "an image asks for interpolation"));
}
let tif = cmyk_tiff();
let mut c = Document::new(&NewDocument::default());
place(&mut c, "scan.tif", tif, (4.0, 2.0), (72.0, 72.0));
assert_eq!(interpolated(&x4(&c).bytes), 0, "CMYK TIFF");
}
/// A 4 × 2 CMYK TIFF, 100% K.
fn cmyk_tiff() -> Vec<u8> {
let mut b = Vec::new();
tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b))
.unwrap()
.write_image::<tiff::encoder::colortype::CMYK8>(4, 2, &[0u8, 0, 0, 255].repeat(8))
.unwrap();
b
}
/// The XMP states the trapping state as the document information does.
#[test]
fn xmp_states_trapped() {
let r = x4(&Document::new(&NewDocument::default()));
let pdf = hayro_syntax::Pdf::new(r.bytes.clone()).unwrap();
let xref = pdf.xref();
let catalog = xref.get::<hayro_syntax::object::Dict>(xref.root_id()).unwrap();
let xmp = catalog.get::<hayro_syntax::object::Stream>(b"Metadata").unwrap().decoded().unwrap().into_owned();
let xmp = String::from_utf8_lossy(&xmp);
assert!(xmp.contains("xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Trapped>False</pdf:Trapped>"), "{xmp}");
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
}