PERF-PDF: reuse image conversions and bound page-object lookups

This commit is contained in:
skylar taylor-barrick 2026-10-08 15:47:17 -06:00
parent abf9a40dc0
commit 20c8c095ff
10 changed files with 1121 additions and 6 deletions

View File

@ -102,9 +102,46 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
if is_psd(bytes) {
return psd::decode(bytes);
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?))
}
/// Fresh raster decodes are sRGB in the pinned image decoder. Expanding an L8 value to
/// [gray, gray, gray, 255] is exact and avoids its generic floating-point conversion.
fn decode_to_rgba8(img: &image::DynamicImage) -> image::RgbaImage {
let prepared = match img {
image::DynamicImage::ImageLuma8(gray) => prepare_luma8_rgba(gray),
_ => None,
};
rgba_or_original(img, prepared)
}
// Keep preparation failure on the existing conversion/error path. This small boundary also
// lets tests force failure deterministically without exhausting memory or changing an allocator.
fn rgba_or_original(img: &image::DynamicImage, prepared: Option<image::RgbaImage>) -> image::RgbaImage {
prepared.unwrap_or_else(|| img.to_rgba8())
}
fn prepare_luma8_rgba(gray: &image::GrayImage) -> Option<image::RgbaImage> {
let (width, height) = gray.dimensions();
let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?;
let length = pixels.checked_mul(4)?;
let mut bytes = Vec::new();
// Unlike the generic ConvertBuffer API's infallible allocation, preserve a route back to
// the original to_rgba8 behavior when the requested buffer cannot be reserved.
bytes.try_reserve_exact(length).ok()?;
bytes.resize(length, 0);
for (output, input) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(gray.pixels()) {
let value = input.0[0];
output.copy_from_slice(&[value, value, value, 255]);
}
let mut rgba = image::RgbaImage::from_raw(width, height, bytes)?;
rgba.set_color_space(gray.color_space()).ok()?;
Some(rgba)
}
#[cfg(test)]
mod luma8_tests;
/// A CMYK raster's ink values, 8 bits per ink (0 = no ink, 255 = solid), as PDF's DeviceCMYK
/// reads them.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]

View File

@ -0,0 +1,116 @@
use super::{decode_to_rgba8, prepare_luma8_rgba, rgba_or_original};
use image::metadata::{CicpColorPrimaries as Primaries, CicpTransferCharacteristics as Transfer};
use image::{DynamicImage, GrayImage, RgbaImage};
const PRIMARIES: [Primaries; 12] = [
Primaries::SRgb,
Primaries::Unspecified,
Primaries::RgbM,
Primaries::RgbB,
Primaries::Bt601,
Primaries::Rgb240m,
Primaries::GenericFilm,
Primaries::Rgb2020,
Primaries::Xyz,
Primaries::SmpteRp431,
Primaries::SmpteRp432,
Primaries::Industry22,
];
const TRANSFERS: [Transfer; 17] = [
Transfer::Bt709,
Transfer::Unspecified,
Transfer::Bt470M,
Transfer::Bt470BG,
Transfer::Bt601,
Transfer::Smpte240m,
Transfer::Linear,
Transfer::Log100,
Transfer::LogSqrt,
Transfer::Iec61966_2_4,
Transfer::Bt1361,
Transfer::SRgb,
Transfer::Bt2020_10bit,
Transfer::Bt2020_12bit,
Transfer::Smpte2084,
Transfer::Smpte428,
Transfer::Bt2100Hlg,
];
#[test]
fn every_luma8_value_and_representable_cicp_match_old_and_independent_oracles() {
for primaries in PRIMARIES {
for transfer in TRANSFERS {
let mut gray = GrayImage::from_raw(256, 1, (0u8..=255).collect()).unwrap();
gray.set_rgb_primaries(primaries);
gray.set_transfer_function(transfer);
let original_color = gray.color_space();
let source = DynamicImage::ImageLuma8(gray);
let old = source.to_rgba8();
let new = decode_to_rgba8(&source);
assert_eq!(new.dimensions(), (256, 1));
assert_eq!(new.as_raw(), old.as_raw(), "{primaries:?} {transfer:?}");
assert_eq!(new.color_space(), original_color);
assert_eq!(new.color_space(), old.color_space());
for (value, pixel) in (0u8..=255).zip(new.pixels()) {
assert_eq!(pixel.0, [value, value, value, 255]);
}
}
}
}
#[test]
fn zero_non_square_and_chunk_boundary_dimensions_match() {
for (width, height) in [(0, 0), (0, 7), (7, 0), (3, 7), (255, 1), (256, 1), (257, 1)] {
let length = width as usize * height as usize;
let bytes = (0..length).map(|n| (n % 256) as u8).collect();
let source = DynamicImage::ImageLuma8(GrayImage::from_raw(width, height, bytes).unwrap());
let old = source.to_rgba8();
let new = decode_to_rgba8(&source);
assert_eq!(new.dimensions(), old.dimensions());
assert_eq!(new.color_space(), old.color_space());
assert_eq!(new.as_raw(), old.as_raw());
}
}
#[test]
fn unused_backing_elements_do_not_enter_the_output() {
let gray = GrayImage::from_raw(2, 1, vec![13, 27, 222, 223, 224]).unwrap();
let prepared = prepare_luma8_rgba(&gray).unwrap();
assert_eq!(prepared.as_raw(), &[13, 13, 13, 255, 27, 27, 27, 255]);
assert_eq!(prepared, DynamicImage::ImageLuma8(gray).to_rgba8());
}
#[test]
fn forced_preparation_failure_uses_original_conversion_and_metadata() {
let mut gray = GrayImage::from_raw(3, 1, vec![0, 129, 255]).unwrap();
gray.set_rgb_primaries(Primaries::Rgb2020);
gray.set_transfer_function(Transfer::Smpte2084);
let source = DynamicImage::ImageLuma8(gray);
let old = source.to_rgba8();
let fallback = rgba_or_original(&source, None);
assert_eq!(fallback.as_raw(), old.as_raw());
assert_eq!(fallback.dimensions(), old.dimensions());
assert_eq!(fallback.color_space(), old.color_space());
}
#[test]
fn non_luma8_variants_keep_original_conversion() {
let variants = [
DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, image::Rgba([200, 30, 90, 0]))),
DynamicImage::ImageRgb8(image::RgbImage::from_pixel(3, 2, image::Rgb([17, 29, 43]))),
DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_pixel(3, 2, image::LumaA([129, 67]))),
DynamicImage::ImageLuma16(image::ImageBuffer::from_pixel(3, 2, image::Luma([129u16]))),
DynamicImage::ImageLumaA16(image::ImageBuffer::from_pixel(3, 2, image::LumaA([65406u16, 32896]))),
DynamicImage::ImageRgb16(image::ImageBuffer::from_pixel(3, 2, image::Rgb([128u16, 129, 65535]))),
DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel(3, 2, image::Rgba([128u16, 129, 65535, 32896]))),
DynamicImage::ImageRgb32F(image::ImageBuffer::from_pixel(3, 2, image::Rgb([0.1f32, 0.5, 0.9]))),
DynamicImage::ImageRgba32F(image::ImageBuffer::from_pixel(3, 2, image::Rgba([0.1f32, 0.5, 0.9, 0.25]))),
];
for source in variants {
let old = source.to_rgba8();
let new = decode_to_rgba8(&source);
assert_eq!(new.as_raw(), old.as_raw());
assert_eq!(new.dimensions(), old.dimensions());
assert_eq!(new.color_space(), old.color_space());
}
}

View File

@ -0,0 +1,112 @@
//! Original small fixtures pin the straight RGBA8 conversion contract.
use image::{DynamicImage, ImageFormat, RgbaImage};
fn encoded(image: &DynamicImage, format: ImageFormat) -> Vec<u8> {
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), format).unwrap();
bytes
}
fn assert_pixels(source: DynamicImage, expected: &[u8]) {
for format in [ImageFormat::Png, ImageFormat::Tiff] {
let bytes = encoded(&source, format);
let original = bytes.clone();
let result = designcraft_images::decode_rgba(&bytes).unwrap();
assert_eq!(result.dimensions(), (2, 2));
assert_eq!(result.as_raw(), expected, "{format:?}");
assert_eq!(bytes, original);
// The reference is the existing borrowed-conversion API, not the proposed owned one.
assert_eq!(result, image::load_from_memory(&bytes).unwrap().to_rgba8());
}
}
#[test]
fn rgba8_keeps_hidden_rgb_and_straight_partial_alpha() {
let pixels = [200, 30, 90, 0, 20, 170, 240, 1, 180, 40, 70, 128, 5, 250, 90, 255];
let source = DynamicImage::ImageRgba8(RgbaImage::from_raw(2, 2, pixels.to_vec()).unwrap());
assert_pixels(source, &pixels);
}
#[test]
fn rgb8_only_adds_opaque_alpha() {
let source = DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 2, vec![200, 30, 90, 20, 170, 240, 180, 40, 70, 5, 250, 90]).unwrap());
assert_pixels(source, &[200, 30, 90, 255, 20, 170, 240, 255, 180, 40, 70, 255, 5, 250, 90, 255]);
}
#[test]
fn grayscale8_matches_fixed_rgb_values() {
let source = DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 2, vec![0, 1, 128, 255]).unwrap());
assert_pixels(source, &[0, 0, 0, 255, 1, 1, 1, 255, 128, 128, 128, 255, 255, 255, 255, 255]);
}
#[test]
fn grayscale16_uses_existing_eight_bit_scaling() {
let source = DynamicImage::ImageLuma16(image::ImageBuffer::from_raw(2, 2, vec![0, 257, 32896, 65535]).unwrap());
assert_pixels(source, &[0, 0, 0, 255, 1, 1, 1, 255, 128, 128, 128, 255, 255, 255, 255, 255]);
}
#[test]
fn grayscale16_rounding_boundaries_match_fixed_values() {
let source = DynamicImage::ImageLuma16(image::ImageBuffer::from_raw(2, 2, vec![128, 129, 65406, 65407]).unwrap());
assert_pixels(source, &[0, 0, 0, 255, 1, 1, 1, 255, 254, 254, 254, 255, 255, 255, 255, 255]);
}
#[test]
fn rgba16_preserves_straight_alpha_and_existing_scaling() {
let source = DynamicImage::ImageRgba16(
image::ImageBuffer::from_raw(2, 2, vec![65535, 0, 32896, 0, 0, 65535, 257, 257, 32896, 257, 65535, 32896, 257, 32896, 0, 65535]).unwrap(),
);
assert_pixels(source, &[255, 0, 128, 0, 0, 255, 1, 1, 128, 1, 255, 128, 1, 128, 0, 255]);
}
#[test]
fn malformed_rasters_remain_unsupported_without_mutating_input() {
let png = encoded(&DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, image::Rgba([13, 27, 42, 128]))), ImageFormat::Png);
let tiff = encoded(&DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, image::Rgba([13, 27, 42, 128]))), ImageFormat::Tiff);
for input in [b"".as_slice(), b"not a raster", &png[..16], &tiff[..6]] {
let original = input.to_vec();
assert!(designcraft_images::decode_rgba(input).is_none());
assert_eq!(input, original);
}
}
#[test]
fn all_gray_bytes_survive_png_and_tiff_decoding() {
let bytes: Vec<u8> = (0u8..=255).collect();
let source = DynamicImage::ImageLuma8(image::GrayImage::from_raw(256, 1, bytes).unwrap());
for format in [ImageFormat::Png, ImageFormat::Tiff] {
let encoded = encoded(&source, format);
let old = image::load_from_memory(&encoded).unwrap().to_rgba8();
let actual = designcraft_images::decode_rgba(&encoded).unwrap();
assert_eq!(actual.dimensions(), (256, 1));
assert_eq!(actual.as_raw(), old.as_raw());
assert_eq!(actual.color_space(), old.color_space());
for (value, pixel) in (0u8..=255).zip(actual.pixels()) {
assert_eq!(pixel.0, [value, value, value, 255]);
}
}
}
#[test]
fn grayscale_eps_proxy_keeps_all_values_and_source_bytes() {
let source = DynamicImage::ImageLuma8(image::GrayImage::from_raw(256, 1, (0u8..=255).collect()).unwrap());
let tiff = encoded(&source, ImageFormat::Tiff);
// Original DOS EPS wrapper with this test's generated grayscale TIFF preview.
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 256 1\n%%EOF\n";
let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6];
for value in [30, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] {
eps.extend_from_slice(&u32::try_from(value).unwrap().to_le_bytes());
}
eps.extend_from_slice(&[0xFF, 0xFF]);
eps.extend_from_slice(ps);
eps.extend_from_slice(&tiff);
let original = eps.clone();
let old = image::load_from_memory(&tiff).unwrap().to_rgba8();
// Current main exposes EPS proxies separately; automatic EPS raster routing is PR150.
let (proxy, _) = designcraft_images::eps_proxy(&eps).unwrap();
let actual = designcraft_images::decode_rgba(&proxy).unwrap();
assert_eq!(actual.dimensions(), (256, 1));
assert_eq!(actual.as_raw(), old.as_raw());
assert_eq!(actual.color_space(), old.color_space());
assert_eq!(eps, original);
}

View File

@ -147,6 +147,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
clip: Rect::ZERO,
warnings,
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
version,
too_new: Vec::new(),
@ -319,6 +322,9 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
clip: Rect::ZERO,
warnings: Vec::new(),
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
// krilla's default.
version: PdfVersion::Pdf17,
@ -401,6 +407,32 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
#[derive(Clone)]
struct ConvertedImage {
image: Image,
warning: Option<&'static str>,
}
const CMYK_FALLBACK_WARNING: &str = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)";
#[cfg(not(test))]
type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage>;
// Unit tests force collisions through the real cache path; production keeps RandomState.
#[cfg(test)]
type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage, std::hash::BuildHasherDefault<ConstantImageHasher>>;
#[cfg(test)]
#[derive(Default)]
struct ConstantImageHasher;
#[cfg(test)]
impl std::hash::Hasher for ConstantImageHasher {
fn finish(&self) -> u64 {
0
}
fn write(&mut self, _: &[u8]) {}
}
pub(crate) struct Exporter<'a> {
pub doc: &'a Document,
pub cache: &'a Cache,
@ -409,6 +441,11 @@ pub(crate) struct Exporter<'a> {
pub clip: Rect,
pub warnings: Vec<String>,
images: HashMap<AssetId, Option<Image>>,
// Immutable export options (including rgb_only) make bytes the complete conversion key.
// PDF-page/SVG/media routing precedes this path. Failures remain per AssetId.
images_by_content: ImageContentCache,
#[cfg(test)]
image_conversions: usize,
/// Placed PDFs (embedded as vector pages).
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
/// The PDF version written; placed PDFs must not be newer.
@ -1166,7 +1203,19 @@ impl Exporter<'_> {
}
let asset = self.doc.assets.get(&id)?.clone();
let data = asset.data.clone();
if let Some(converted) = self.images_by_content.get(&data).cloned() {
if let Some(warning) = converted.warning {
self.warn(format!("{}: {warning}", asset.name));
}
self.images.insert(id, Some(converted.image.clone()));
return Some(converted.image);
}
let fmt = image::guess_format(&data).ok();
#[cfg(test)]
{
self.image_conversions += 1;
}
let mut warning = None;
let img = match fmt {
Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(),
// A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print
@ -1174,10 +1223,8 @@ impl Exporter<'_> {
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) {
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
));
warning = Some(CMYK_FALLBACK_WARNING);
self.warn(format!("{}: {CMYK_FALLBACK_WARNING}", asset.name));
lossless(&data, fmt)
}
},
@ -1187,6 +1234,11 @@ impl Exporter<'_> {
if img.is_none() {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
}
if let Some(image) = &img {
// Share the original encoded payload. HashMap checks exact bytes after hashing,
// so collisions cannot merge distinct images or their diagnostics.
self.images_by_content.insert(data, ConvertedImage { image: image.clone(), warning });
}
self.images.insert(id, img.clone());
img
}
@ -1342,3 +1394,217 @@ fn recompress(data: &[u8]) -> Option<Image> {
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
Image::from_jpeg(buf.into(), true).ok()
}
#[cfg(test)]
mod image_content_cache_tests {
use super::*;
use designcraft_doc::Asset;
fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(w, h, image::Rgba(color));
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
fn asset(doc: &mut Document, bytes: Vec<u8>, name: &str) -> AssetId {
let id = AssetId(doc.alloc());
doc.assets.insert(
id,
Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(bytes), pixels: None, page: 0 }),
);
id
}
fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> {
Exporter {
doc,
cache,
opts,
clip: Rect::ZERO,
warnings: Vec::new(),
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
image_conversions: 0,
pdfs: HashMap::new(),
version: PdfVersion::Pdf17,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
}
}
#[test]
fn exact_bytes_reuse_conversion_under_real_hash_collisions() {
use std::hash::{BuildHasher, Hasher};
let mut d = Document::new(&Default::default());
let red = png(2, 1, [255, 0, 0, 255]);
let blue = png(1, 2, [0, 0, 255, 128]);
let a1 = asset(&mut d, red.clone(), "a1");
let b1 = asset(&mut d, blue.clone(), "b1");
let a2 = asset(&mut d, red, "a2");
let b2 = asset(&mut d, blue, "b2");
assert!(!Arc::ptr_eq(&d.assets[&a1].data, &d.assets[&a2].data));
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
let mut h1 = ex.images_by_content.hasher().build_hasher();
h1.write(&d.assets[&a1].data);
let mut h2 = ex.images_by_content.hasher().build_hasher();
h2.write(&d.assets[&b1].data);
assert_eq!(h1.finish(), h2.finish());
assert_ne!(d.assets[&a1].data, d.assets[&b1].data);
for (id, count, size) in [(a1, 1, (2, 1)), (b1, 2, (1, 2)), (a2, 2, (2, 1)), (a1, 2, (2, 1)), (b2, 2, (1, 2))] {
assert_eq!(ex.load_image(id).unwrap().size(), size);
assert_eq!(ex.image_conversions, count, "count actual converter executions, not krilla resource equality");
}
assert_eq!(ex.images.len(), 4);
assert_eq!(ex.images_by_content.len(), 2);
let first_key = ex.images_by_content.keys().find(|key| key.as_slice() == d.assets[&a1].data.as_slice()).unwrap();
assert!(Arc::ptr_eq(first_key, &d.assets[&a1].data), "cache keys share the original encoded payload");
ex.images_by_content.clear();
assert_eq!(ex.load_image(a1).unwrap().size(), (2, 1));
assert_eq!(ex.image_conversions, 2, "the AssetId cache remains the first lookup");
assert!(ex.images_by_content.is_empty());
}
#[test]
fn successful_content_cache_does_not_suppress_failure_names() {
let mut d = Document::new(&Default::default());
let a = asset(&mut d, vec![0, 1, 2, 3], "broken-a.png");
let b = asset(&mut d, vec![0, 1, 2, 3], "broken-b.png");
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_none());
assert!(ex.load_image(b).is_none());
assert!(ex.load_image(a).is_none());
assert_eq!(ex.image_conversions, 2);
assert!(ex.images_by_content.is_empty());
assert_eq!(ex.warnings.len(), 2);
assert!(ex.warnings[0].contains("broken-a.png"));
assert!(ex.warnings[1].contains("broken-b.png"));
}
#[test]
fn content_cache_is_scoped_to_each_immutable_options_context() {
let mut d = Document::new(&Default::default());
let data = png(2, 2, [240, 20, 30, 255]);
let a = asset(&mut d, data.clone(), "a");
let b = asset(&mut d, data, "b");
let cache = Cache::new();
for compress_images in [false, true, false] {
let opts = PdfOptions { compress_images, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_some());
assert!(ex.load_image(b).is_some());
assert_eq!(ex.image_conversions, 1);
assert_eq!(ex.images_by_content.len(), 1);
}
}
#[test]
fn successful_cmyk_fallback_replays_each_asset_name_once_and_converts_once() {
let bytes = crate::image_fixtures::cmyk_tiff(None, true);
assert!(designcraft_images::is_cmyk_tiff(&bytes));
assert!(designcraft_images::decode_cmyk_tiff(&bytes).is_none());
assert!(designcraft_images::decode_rgba(&bytes).is_some());
let mut d = Document::new(&Default::default());
let a = asset(&mut d, bytes.clone(), "ink-a.tif");
let b = asset(&mut d, bytes, "ink-b.tif");
let cache = Cache::new();
for compress_images in [false, true] {
let opts = PdfOptions { compress_images, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
for id in [a, b, a, b] {
assert_eq!(ex.load_image(id).unwrap().size(), (2, 1));
}
assert_eq!(ex.image_conversions, 1);
assert_eq!(ex.images_by_content.len(), 1);
assert_eq!(ex.warnings, [format!("ink-a.tif: {CMYK_FALLBACK_WARNING}"), format!("ink-b.tif: {CMYK_FALLBACK_WARNING}")]);
}
}
#[test]
fn cmyk_alpha_and_rgb_only_context_keep_conversion_counts_and_failures() {
let cache = Cache::new();
for alpha in [None, Some(2), Some(1)] {
let mut d = Document::new(&Default::default());
let bytes = crate::image_fixtures::cmyk_tiff(alpha, false);
let a = asset(&mut d, bytes.clone(), "a.tif");
let b = asset(&mut d, bytes, "b.tif");
for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX4, Standard::PdfA2b, Standard::None] {
let opts = PdfOptions { standard, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
ex.rgb_only = standard == Standard::PdfA2b;
// The pinned image decoder does not support CMYKA as RGB. Associated alpha
// is also rejected by the ink-preserving path: retain both failure names.
let succeeds = alpha.is_none() || (alpha == Some(2) && !ex.rgb_only);
let first = ex.load_image(a);
assert_eq!(first.is_some(), succeeds, "{alpha:?} {standard:?}");
if alpha.is_none() {
let (space, samples) = image_color_samples(first.unwrap());
assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" });
assert_eq!(samples, if ex.rgb_only { vec![0, 0, 0, 255, 0, 0] } else { crate::image_fixtures::INKS.to_vec() });
}
assert_eq!(ex.load_image(b).is_some(), succeeds, "{alpha:?} {standard:?}");
assert_eq!(ex.load_image(a).is_some(), succeeds);
assert_eq!(ex.image_conversions, if succeeds { 1 } else { 2 });
assert_eq!(ex.images_by_content.len(), usize::from(succeeds));
if succeeds {
assert!(ex.warnings.is_empty());
} else {
for name in ["a.tif", "b.tif"] {
assert!(ex.warnings.contains(&format!("image `{name}` could not be decoded and was skipped")));
assert_eq!(ex.warnings.contains(&format!("{name}: {CMYK_FALLBACK_WARNING}")), !ex.rgb_only);
}
}
}
}
}
// Serialize the converted raster without an archival validator so the RGB-only pixel
// contract stays independent of main's existing PDF/A interpolation validation failure.
fn image_color_samples(image: Image) -> (String, Vec<u8>) {
use hayro_syntax::object::Name;
let mut pdf = krilla::Document::new();
let size = Size::from_wh(20.0, 20.0).unwrap();
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut surface = page.surface();
surface.draw_image(image, size);
surface.finish();
page.finish();
let bytes = pdf.finish().unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let stream = pdf
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.find(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.unwrap();
(stream.dict().get::<Name>("ColorSpace").unwrap().as_str().to_owned(), stream.decoded().unwrap().into_owned())
}
#[test]
fn content_cache_owns_only_existing_payloads_and_drops_with_exporter() {
let mut d = Document::new(&Default::default());
let bytes = png(2, 1, [20, 30, 40, 255]);
let a = asset(&mut d, bytes.clone(), "a.png");
let b = asset(&mut d, bytes, "b.png");
let payload = Arc::downgrade(&d.assets[&a].data);
let duplicate = Arc::downgrade(&d.assets[&b].data);
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_some());
assert!(ex.load_image(b).is_some());
assert_eq!(duplicate.strong_count(), 1, "a content hit must not retain the second encoded allocation");
let key = ex.images_by_content.keys().next().unwrap();
assert!(Arc::ptr_eq(key, &d.assets[&a].data));
drop(ex);
drop(d);
assert!(payload.upgrade().is_none(), "no converted image or encoded key outlives this exporter");
assert!(duplicate.upgrade().is_none());
}
}

View File

@ -200,3 +200,14 @@ pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> {
#[cfg(test)]
mod tests;
// Run the same public API/output contracts with the unit-only colliding cache hasher as well
// as the production RandomState used by the integration-test executable.
#[cfg(test)]
extern crate self as designcraft_pdf;
#[cfg(test)]
#[path = "../tests/support/image_content_cache_contract.rs"]
mod image_content_cache_collision_exports;
#[cfg(test)]
#[path = "../tests/support/image_fixtures.rs"]
mod image_fixtures;

View File

@ -3,6 +3,7 @@
//! a new cross-reference section pointing back at the original.
use designcraft_doc::{PageTransition, TransitionKind};
use std::collections::HashMap;
/// The `/Trans` dictionary for a transition.
fn trans_dict(t: &PageTransition) -> String {
@ -51,6 +52,56 @@ pub(crate) fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> {
Some(start..end)
}
// Bound optional indexing metadata. Larger requests retain the original lookup path.
const MAX_INDEXED_PAGE_OBJECTS: usize = 4096;
/// Latest literal object headers for the requested IDs, found in a single reverse pass.
/// Match `object` exactly, including embedded headers and the lack of a boundary after `obj`.
/// Only requested IDs are retained; neither PDF size nor the largest ID sizes this map.
/// If the metadata limit or an allocation failure prevents indexing, use `object` instead.
fn object_starts(pdf: &[u8], ids: impl Iterator<Item = usize>) -> Option<HashMap<usize, Option<usize>>> {
let mut starts = HashMap::new();
for id in ids {
if starts.contains_key(&id) {
continue;
}
if starts.len() == MAX_INDEXED_PAGE_OBJECTS || starts.try_reserve(1).is_err() {
return None;
}
starts.insert(id, None);
}
let mut missing = starts.len();
if missing == 0 {
return Some(starts);
}
let mut end = pdf.len();
while let Some(at) = pdf.get(..end).and_then(|bytes| bytes.iter().rposition(|b| *b == b'\n')) {
let Some(start) = at.checked_add(1) else { break };
let Some(line) = pdf.get(start..end) else { break };
end = at;
let digits = line.iter().position(|b| !b.is_ascii_digit()).unwrap_or(line.len());
let Some(number) = line.get(..digits) else { continue };
if number.is_empty() || (number.len() > 1 && number.first() == Some(&b'0')) {
continue;
}
if !line.get(digits..).is_some_and(|tail| tail.starts_with(b" 0 obj")) {
continue;
}
let Some(id) = number.iter().try_fold(0usize, |n, b| n.checked_mul(10)?.checked_add(usize::from(*b - b'0'))) else { continue };
let Some(slot) = starts.get_mut(&id) else { continue };
if slot.is_some() {
continue;
}
let Some(body) = start.checked_add(digits).and_then(|n| n.checked_add(6)) else { continue };
*slot = Some(body);
missing -= 1;
if missing == 0 {
break;
}
}
Some(starts)
}
/// Add transitions to the pages of `pdf` (index = PDF page). `None` when the file's structure
/// isn't the simple kind this understands (classic xref table, a flat page tree).
pub fn add_transitions(pdf: &[u8], trans: &[Option<PageTransition>]) -> Option<Vec<u8>> {
@ -84,6 +135,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>>
let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
let nums: Vec<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect();
let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid)));
let mut out = pdf.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
@ -91,7 +143,13 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>>
let mut entries: Vec<(usize, usize)> = Vec::new();
for (i, t) in extra.iter().enumerate() {
let (Some(t), Some(&kid)) = (t, kids.get(i)) else { continue };
let body = String::from_utf8_lossy(&pdf[object(pdf, kid)?]).to_string();
let range = if let Some(starts) = &starts {
let start = starts.get(&kid).copied().flatten()?;
start..find(pdf, b"endobj", start)?
} else {
object(pdf, kid)?
};
let body = String::from_utf8_lossy(&pdf[range]).to_string();
if !body.contains("/Type/Page") && !body.contains("/Type /Page") {
return None;
}
@ -140,3 +198,19 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option<Vec<u8>> {
);
Some(out)
}
#[cfg(test)]
mod object_index_tests {
use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts};
#[test]
fn metadata_limit_disables_only_the_optional_index() {
let at_limit = object_starts(b"", 0..MAX_INDEXED_PAGE_OBJECTS).unwrap();
assert_eq!(at_limit.len(), MAX_INDEXED_PAGE_OBJECTS);
assert!(object_starts(b"", 0..=MAX_INDEXED_PAGE_OBJECTS).is_none());
// Duplicate requests do not consume the unique-ID budget.
let starts = object_starts(b"\n0 0 objendobj", std::iter::repeat_n(0, MAX_INDEXED_PAGE_OBJECTS + 1)).unwrap();
assert_eq!(starts.len(), 1);
assert_eq!(starts.get(&0), Some(&Some(8)));
}
}

View File

@ -0,0 +1,5 @@
//! Run public contracts with the production content-cache hasher.
#[path = "support/image_content_cache_contract.rs"]
mod contracts;
#[path = "support/image_fixtures.rs"]
mod image_fixtures;

View File

@ -0,0 +1,147 @@
//! Independent literal-search contract tests. These pass on the old implementation too:
//! this optimization must preserve the current deliberately narrow PDF handling.
use designcraft_pdf::add_page_entries;
fn fixture(kids: &str, objects: &[u8]) -> Vec<u8> {
let mut pdf =
format!("%PDF-1.7\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n2 0 obj\n<</Type/Pages/Kids[{kids}]/Count 2>>\nendobj\n").into_bytes();
pdf.extend_from_slice(objects);
pdf.extend_from_slice(b"\ntrailer\n<</Size 10/Root 1 0 R>>\nstartxref\n0\n%%EOF\n");
pdf
}
fn old_page() -> &'static [u8] {
b"3 0 obj\n<</Type/Page/Marker/old>>\nendobj\n"
}
fn update(pdf: &[u8], extras: &[Option<&str>]) -> Option<Vec<u8>> {
add_page_entries(pdf, &extras.iter().map(|s| s.map(str::to_owned)).collect::<Vec<_>>())
}
fn appended<'a>(pdf: &[u8], out: &'a [u8]) -> &'a [u8] {
assert!(out.starts_with(pdf));
&out[pdf.len() + usize::from(!pdf.ends_with(b"\n"))..]
}
#[test]
fn newest_copy_is_selected_and_duplicates_keep_request_order() {
let mut objects = old_page().to_vec();
objects.extend_from_slice(b"3 0 obj\n<</Type/Page/Marker/new>>\nendobj\n");
let pdf = fixture("3 0 R 3 0 R", &objects);
let out = update(&pdf, &[Some("/A 1"), Some("/B 2")]).unwrap();
assert!(
appended(&pdf, &out).starts_with(b"3 0 obj\n<</Type/Page/Marker/new/A 1>>\nendobj\n3 0 obj\n<</Type/Page/Marker/new/B 2>>\nendobj\nxref\n")
);
}
#[test]
fn embedded_header_and_object_suffix_are_literal_matches() {
let mut objects = old_page().to_vec();
objects.extend_from_slice(b"8 0 obj\nstream\n3 0 object<</Type/Page/Marker/embedded>>endobj\nendstream\nendobj\n");
let pdf = fixture("3 0 R", &objects);
let out = update(&pdf, &[Some("/A 1")]).unwrap();
assert!(appended(&pdf, &out).starts_with(b"3 0 object<</Type/Page/Marker/embedded/A 1>>endobj\nxref\n"));
}
#[test]
fn latest_unterminated_match_does_not_fall_back() {
let mut pdf = fixture("3 0 R", old_page());
pdf.extend_from_slice(b"3 0 obj<</Type/Page/Marker/unterminated>>");
assert_eq!(update(&pdf, &[Some("/A 1")]), None);
}
#[test]
fn latest_nonpage_and_missing_requested_page_are_rejected() {
let mut objects = old_page().to_vec();
objects.extend_from_slice(b"3 0 obj<</Type/Other>>endobj\n");
assert_eq!(update(&fixture("3 0 R", &objects), &[Some("/A 1")]), None);
assert_eq!(update(&fixture("7 0 R", old_page()), &[Some("/A 1")]), None);
}
#[test]
fn only_canonical_decimal_and_exact_generation_spacing_match() {
for header in ["03 0 obj", "+3 0 obj", "3 1 obj", "3 0 obj", "3\t0 obj", "3 0\tobj", "184467440737095516160 0 obj"] {
let mut objects = old_page().to_vec();
objects.extend_from_slice(format!("{header}<</Type/Page/Marker/wrong>>endobj\n").as_bytes());
let pdf = fixture("3 0 R", &objects);
let out = update(&pdf, &[Some("/A 1")]).unwrap();
assert!(appended(&pdf, &out).starts_with(b"3 0 obj\n<</Type/Page/Marker/old/A 1>>\nendobj\nxref\n"), "{header}");
}
}
#[test]
fn crlf_matches_but_bare_cr_and_byte_zero_do_not() {
let mut objects = old_page().to_vec();
objects.extend_from_slice(b"\r\n3 0 obj<</Type/Page/Marker/crlf>>endobj\r3 0 obj<</Type/Page/Marker/barecr>>endobj\n");
let pdf = fixture("3 0 R", &objects);
let out = update(&pdf, &[Some("/A 1")]).unwrap();
assert!(appended(&pdf, &out).starts_with(b"3 0 obj<</Type/Page/Marker/crlf/A 1>>endobj\nxref\n"));
let mut pdf = old_page().to_vec();
pdf.extend_from_slice(&fixture("3 0 R", b""));
assert_eq!(update(&pdf, &[Some("/A 1")]), None);
}
#[test]
fn first_subsequent_endobj_and_lossy_body_are_preserved() {
let pdf = fixture("3 0 R", b"3 0 obj<</Type/Page/Marker/\xff>>endobj IGNORE endobj\n");
let out = update(&pdf, &[Some("/A 1")]).unwrap();
assert!(appended(&pdf, &out).starts_with("3 0 obj<</Type/Page/Marker/\u{fffd}/A 1>>endobj\nxref\n".as_bytes()));
}
#[test]
fn absent_extras_do_not_require_objects_and_out_of_range_requests_are_ignored() {
let malformed = b"not a PDF";
assert_eq!(update(malformed, &[]).unwrap(), malformed);
assert_eq!(update(malformed, &[None, None]).unwrap(), malformed);
let pdf = fixture("7 0 R 3 0 R", old_page());
let out = update(&pdf, &[None, Some("/A 1"), Some("/Ignored 1")]).unwrap();
assert!(appended(&pdf, &out).starts_with(b"3 0 obj\n<</Type/Page/Marker/old/A 1>>\nendobj\nxref\n"));
assert!(!out.windows(b"/Ignored".len()).any(|w| w == b"/Ignored"));
}
#[test]
fn zero_and_maximum_usize_ids_are_supported_without_index_sizing_by_id() {
for id in [0, usize::MAX] {
let pdf = fixture(&format!("{id} 0 R"), format!("{id} 0 obj<</Type/Page/Marker/extreme>>endobj\n").as_bytes());
let out = update(&pdf, &[Some("/A 1")]).unwrap();
assert!(appended(&pdf, &out).starts_with(format!("{id} 0 obj<</Type/Page/Marker/extreme/A 1>>endobj\nxref\n").as_bytes()));
}
}
/// A complete, blank PDF with correct classic xref offsets and 4,097 distinct pages.
/// This deliberately exceeds the optional index's documented 4,096-entry budget.
pub(crate) fn over_index_budget_fixture() -> (Vec<u8>, Vec<Option<String>>) {
let count = 4097;
let size = count + 3;
let kids = (3..size).map(|id| format!("{id} 0 R ")).collect::<String>();
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0usize];
for (id, body) in [(1, "<</Type/Catalog/Pages 2 0 R>>".to_owned()), (2, format!("<</Type/Pages/Kids[{kids}]/Count {count}>>"))] {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{id} 0 obj\n{body}\nendobj\n").as_bytes());
}
for id in 3..size {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{id} 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]/Marker {id}>>\nendobj\n").as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes());
for offset in offsets.iter().skip(1) {
pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<</Size {size}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let extras = (3..size).map(|id| Some(format!("/Tag {id}"))).collect();
(pdf, extras)
}
#[test]
fn index_budget_fallback_preserves_every_requested_page_update() {
let (pdf, extras) = over_index_budget_fixture();
assert_eq!(extras.len(), 4097);
let out = add_page_entries(&pdf, &extras).unwrap();
let mut remaining = appended(&pdf, &out);
for id in 3..4100 {
let expected = format!("{id} 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]/Marker {id}/Tag {id}>>\nendobj\n");
assert!(remaining.starts_with(expected.as_bytes()), "missing or changed page {id}");
remaining = &remaining[expected.len()..];
}
assert!(remaining.starts_with(b"xref\n"));
assert!(remaining.windows(b"4099 1\n".len()).any(|w| w == b"4099 1\n"));
}

View File

@ -0,0 +1,324 @@
//! Public-export controls run against the production RandomState cache, independent of the
//! unit-only forced-collision hasher. Also included by the library unit-test target.
//! All images and placed PDFs are tiny procedural fixtures.
use std::sync::Arc;
use designcraft_compose::Cache;
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef};
use designcraft_geom::{Affine, Rect, shapes};
use designcraft_pdf::{BookletOptions, PdfError, PdfOptions, Standard, export_booklet, export_pdf, export_pdf_with_report};
use crate::image_fixtures as fixtures;
fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba(color));
let mut bytes = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
fn place(d: &mut Document, data: Vec<u8>, x: f64) {
place_named(d, data, x, &format!("original-{x}.png"), "image/png", 0);
}
fn place_named(d: &mut Document, data: Vec<u8>, x: f64, name: &str, mime: &str, page: u32) {
let aid = AssetId(d.alloc());
d.assets.insert(aid, Arc::new(Asset { id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(data), pixels: None, page }));
let id = ItemId(d.alloc());
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0)));
item.stroke.weight = 0.0;
item.content = Content::Graphic(Graphic {
asset: aid,
size: (20.0, 20.0),
xf: Affine::translate((x, 36.0)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
#[derive(Debug)]
struct DrawnImage {
size: (u32, u32),
rgb: [u8; 3],
alpha: u8,
x: f64,
}
fn drawn_images(bytes: Vec<u8>) -> Vec<DrawnImage> {
drawn_content(bytes).0
}
fn drawn_content(bytes: Vec<u8>) -> (Vec<DrawnImage>, Vec<([u8; 4], kurbo::Rect)>) {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use kurbo::Shape as _;
struct Sink(Vec<DrawnImage>, Vec<([u8; 4], kurbo::Rect)>);
impl Device<'_> for Sink {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) {
if let (Paint::Color(color), PathDrawMode::Fill(_)) = (paint, mode) {
self.1.push((color.to_rgba().to_rgba8(), (transform * path.clone()).bounding_box()));
}
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, image: Image<'_, '_>, transform: kurbo::Affine) {
if let Image::Raster(image) = image {
image.with_rgba(
|pixels, alpha| {
let size = (pixels.width(), pixels.height());
let rgb = match pixels {
ImageData::Rgb(p) => [p.data[0], p.data[1], p.data[2]],
ImageData::Luma(p) => [p.data[0]; 3],
};
self.0.push(DrawnImage { size, rgb, alpha: alpha.map_or(255, |p| p.data[0]), x: transform.as_coeffs()[4] });
},
None,
);
}
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let cache = InterpreterCache::new();
let mut sink = Sink(Vec::new(), Vec::new());
for page in pdf.pages().iter() {
let mut context =
Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1000.0, 1000.0), &cache, pdf.xref(), InterpreterSettings::default());
interpret_page(page, &mut context, &mut sink);
}
(sink.0, sink.1)
}
#[test]
fn repeated_svg_assets_keep_vector_paths_and_placement() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect x="2" y="3" width="5" height="7" fill="red"/></svg>"#;
let mut d = Document::new(&Default::default());
for (name, x) in [("vector-a.svg", 36.0), ("vector-b.svg", 66.0)] {
place_named(&mut d, svg.to_vec(), x, name, "image/svg+xml", 0);
}
let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
assert!(image_objects(&report.bytes).is_empty(), "placed SVGs must stay vector artwork");
let (images, paths) = drawn_content(report.bytes);
assert!(images.is_empty());
let red: Vec<_> = paths.into_iter().filter(|(color, _)| *color == [255, 0, 0, 255]).collect();
assert_eq!(red.len(), 2, "both SVG assets must paint the original rectangle");
for ((_, bounds), x) in red.iter().zip([38.0, 68.0]) {
assert!((bounds.x0 - x).abs() < 1e-6, "{bounds:?}");
assert!((bounds.width() - 5.0).abs() < 1e-6, "{bounds:?}");
assert!((bounds.height() - 7.0).abs() < 1e-6, "{bounds:?}");
}
}
#[test]
fn repeated_encoded_assets_preserve_colors_alpha_dimensions_and_placement() {
let mut d = Document::new(&Default::default());
let red = png(2, 1, [255, 0, 0, 255]);
let blue = png(1, 2, [0, 0, 255, 128]);
for (data, x) in [(red.clone(), 36.0), (blue.clone(), 66.0), (red, 96.0), (blue, 126.0)] {
place(&mut d, data, x);
}
d.check().unwrap();
let got = drawn_images(export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
assert_eq!(got.len(), 4);
for (index, item) in got.iter().enumerate() {
assert_eq!(item.size, if index % 2 == 0 { (2, 1) } else { (1, 2) });
assert_eq!(item.rgb, if index % 2 == 0 { [255, 0, 0] } else { [0, 0, 255] });
assert_eq!(item.alpha, if index % 2 == 0 { 255 } else { 128 });
assert!((item.x - (36.0 + index as f64 * 30.0)).abs() < 1e-6, "{got:?}");
}
}
#[test]
fn compress_images_context_does_not_leak_between_exports() {
let mut d = Document::new(&Default::default());
let data = png(4, 4, [220, 30, 40, 255]);
place(&mut d, data.clone(), 36.0);
place(&mut d, data, 66.0);
for compress_images in [false, true, false] {
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap();
let has_dct = bytes.windows(b"/DCTDecode".len()).any(|w| w == b"/DCTDecode");
assert_eq!(has_dct, compress_images, "export options must remain independent");
}
}
#[test]
fn repeated_png_with_deferred_decode_error_still_fails_export() {
let mut bad = png(2, 2, [255, 0, 0, 255]);
let idat = bad.windows(4).position(|w| w == b"IDAT").unwrap();
bad[idat + 4] ^= 255;
// Metadata succeeds, while corrupted IDAT decoding fails later in the backend.
assert!(krilla::image::Image::from_png(Arc::new(bad.clone()).into(), true).is_ok());
let mut d = Document::new(&Default::default());
place(&mut d, bad.clone(), 36.0);
place(&mut d, bad, 66.0);
let error = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap_err();
assert!(matches!(error, PdfError::Write(_)), "{error}");
}
#[derive(Debug, PartialEq)]
struct ImageObject {
space: String,
size: (i32, i32),
samples: Vec<u8>,
alpha: Option<Vec<u8>>,
}
fn image_objects(bytes: &[u8]) -> Vec<ImageObject> {
use hayro_syntax::object::{Name, Stream};
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>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| ImageObject {
space: s.dict().get::<Name>("ColorSpace").map_or("ICC".into(), |n| n.as_str().to_owned()),
size: (s.dict().get::<i32>("Width").unwrap(), s.dict().get::<i32>("Height").unwrap()),
samples: s.decoded().unwrap().into_owned(),
alpha: s.dict().get::<Stream<'_>>("SMask").map(|a| a.decoded().unwrap().into_owned()),
})
.collect()
}
#[test]
fn repeated_cmyk_tiffs_keep_exact_inks_and_unassociated_alpha() {
for alpha in [None, Some(2)] {
let bytes = fixtures::cmyk_tiff(alpha, false);
let mut d = Document::new(&Default::default());
for (name, x) in [("a.tif", 36.0), ("b.tif", 66.0)] {
place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0);
}
for standard in [Standard::None, Standard::PdfX4] {
for compress_images in [false, true] {
let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, compress_images, ..Default::default() }).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let images = image_objects(&report.bytes);
let cmyk: Vec<_> = images.iter().filter(|i| i.space == "DeviceCMYK").collect();
assert_eq!(cmyk.len(), 1, "{images:?}");
assert_eq!(cmyk[0].size, (2, 1));
assert_eq!(cmyk[0].samples, fixtures::INKS);
assert_eq!(cmyk[0].alpha, alpha.map(|_| fixtures::ALPHA.to_vec()));
let drawn = drawn_images(report.bytes);
assert_eq!(drawn.len(), 2);
assert_eq!(drawn.iter().map(|i| i.x).collect::<Vec<_>>(), [36.0, 66.0]);
}
}
}
}
#[test]
fn successful_fallback_warns_for_both_names_and_preserves_rgb_samples() {
let bytes = fixtures::cmyk_tiff(None, true);
let mut d = Document::new(&Default::default());
for (name, x) in [("other-inks-a.tif", 36.0), ("other-inks-b.tif", 66.0)] {
place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0);
}
let reason = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)";
let expected_warnings = [format!("other-inks-a.tif: {reason}"), format!("other-inks-b.tif: {reason}")];
for compress_images in [false, true] {
let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap();
assert_eq!(report.warnings, expected_warnings);
let images = image_objects(&report.bytes);
assert_eq!(images.len(), 1);
assert_eq!(images[0].space, "DeviceRGB");
assert_eq!(images[0].samples, [0, 0, 0, 255, 0, 0]);
assert_eq!(images[0].size, (2, 1));
}
let booklet = export_booklet(&d, &Cache::new(), &BookletOptions::default()).unwrap();
assert_eq!(booklet.warnings, expected_warnings);
assert_eq!(image_objects(&booklet.bytes)[0].samples, [0, 0, 0, 255, 0, 0]);
}
#[test]
fn pdfa_rgb_conversion_does_not_poison_later_cmyk_exports() {
let bytes = fixtures::cmyk_tiff(None, false);
let mut d = Document::new(&Default::default());
place_named(&mut d, bytes.clone(), 36.0, "a.tif", "image/tiff", 0);
place_named(&mut d, bytes, 66.0, "b.tif", "image/tiff", 0);
for standard in [Standard::PdfA2b, Standard::None, Standard::PdfX4, Standard::PdfA2b, Standard::None] {
let result = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, created: Some(1_700_000_000), ..Default::default() });
let report = result.unwrap();
let images = image_objects(&report.bytes);
assert_eq!(images.len(), 1);
if standard == Standard::PdfA2b {
assert_eq!(report.warnings, ["PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)"]);
assert_eq!(images[0].space, "ICC");
assert_eq!(images[0].samples, [0, 0, 0, 255, 0, 0]);
} else {
assert!(report.warnings.is_empty());
assert_eq!(images[0].space, "DeviceCMYK");
assert_eq!(images[0].samples, fixtures::INKS);
}
}
}
#[test]
fn unsupported_associated_alpha_keeps_each_asset_failure_warning() {
let bytes = fixtures::cmyk_tiff(Some(1), false);
let mut d = Document::new(&Default::default());
for (name, x) in [("alpha-a.tif", 36.0), ("alpha-b.tif", 66.0)] {
place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0);
}
let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(image_objects(&report.bytes).is_empty());
assert_eq!(report.warnings.len(), 4);
for name in ["alpha-a.tif", "alpha-b.tif"] {
assert!(report.warnings.iter().any(|w| w.starts_with(&format!("{name}: converted to RGB"))));
assert!(report.warnings.contains(&format!("image `{name}` could not be decoded and was skipped")));
}
}
fn two_page_pdf() -> Vec<u8> {
let mut pdf = krilla::Document::new();
for color in [[255, 0, 0, 255], [0, 0, 255, 255]] {
let mut page = pdf.start_page_with(krilla::page::PageSettings::new(krilla::geom::Size::from_wh(20.0, 20.0).unwrap()));
let mut surface = page.surface();
let image = krilla::image::Image::from_png(Arc::new(png(1, 1, color)).into(), true).unwrap();
surface.draw_image(image, krilla::geom::Size::from_wh(20.0, 20.0).unwrap());
surface.finish();
page.finish();
}
pdf.finish().unwrap()
}
#[test]
fn placed_pdf_selection_and_media_routing_precede_content_cache() {
let mut d = Document::new(&Default::default());
let pdf = two_page_pdf();
place_named(&mut d, pdf.clone(), 36.0, "page-two.pdf", "application/pdf", 1);
place_named(&mut d, pdf, 66.0, "page-one.pdf", "application/pdf", 0);
let red = png(2, 1, [255, 0, 0, 255]);
place_named(&mut d, red.clone(), 96.0, "red.png", "image/png", 0);
place_named(&mut d, red, 126.0, "movie.mp4", "video/mp4", 0);
let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let drawn = drawn_images(report.bytes);
assert_eq!(drawn.len(), 3, "media bytes must draw the existing placeholder, not the matching raster: {drawn:?}");
assert_eq!(drawn.iter().map(|i| (i.rgb, i.size)).collect::<Vec<_>>(), [([0, 0, 255], (1, 1)), ([255, 0, 0], (1, 1)), ([255, 0, 0], (2, 1))]);
assert_eq!(drawn.iter().map(|i| i.x).collect::<Vec<_>>(), [36.0, 66.0, 96.0]);
}
#[test]
fn repeated_direct_pngs_preserve_current_pdfa_interpolation_failure() {
let data = png(2, 1, [220, 30, 40, 255]);
let mut d = Document::new(&Default::default());
place(&mut d, data.clone(), 36.0);
place(&mut d, data, 66.0);
// Unlike TIFF's raw-RGBA conversion, main's direct PNG path interpolates. PR128 owns
// disabling this for PDF/A; caching must preserve the existing validator failure.
let error =
export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() })
.unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("ImageInterpolation"), "{error}");
assert!(!error.to_string().contains("MissingCMYKProfile"), "{error}");
}

View File

@ -0,0 +1,23 @@
//! Original procedural CMYK TIFFs shared by conversion and public-export contract tests.
pub const INKS: [u8; 8] = [0, 0, 0, 255, 0, 255, 255, 0];
pub const ALPHA: [u8; 2] = [255, 128];
pub fn cmyk_tiff(alpha: Option<u16>, other_inks: bool) -> Vec<u8> {
use tiff::encoder::colortype::{CMYK8, CMYKA8};
use tiff::tags::Tag;
let mut bytes = Vec::new();
let mut encoder = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut bytes)).unwrap();
if let Some(kind) = alpha {
let mut image = encoder.new_image::<CMYKA8>(2, 1).unwrap();
image.encoder().write_tag(Tag::ExtraSamples, &[kind][..]).unwrap();
image.write_data(&[0, 0, 0, 255, ALPHA[0], 0, 255, 255, 0, ALPHA[1]]).unwrap();
} else {
let mut image = encoder.new_image::<CMYK8>(2, 1).unwrap();
if other_inks {
image.encoder().write_tag(Tag::Unknown(332), 2u16).unwrap();
}
image.write_data(&INKS).unwrap();
}
bytes
}