diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index b770140..9ae6f3a 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -102,9 +102,46 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { 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 { + prepared.unwrap_or_else(|| img.to_rgba8()) +} + +fn prepare_luma8_rgba(gray: &image::GrayImage) -> Option { + 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)] diff --git a/crates/images/src/luma8_tests.rs b/crates/images/src/luma8_tests.rs new file mode 100644 index 0000000..69e9b40 --- /dev/null +++ b/crates/images/src/luma8_tests.rs @@ -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()); + } +} diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/rgba_conversion_contract.rs new file mode 100644 index 0000000..9f514cf --- /dev/null +++ b/crates/images/tests/rgba_conversion_contract.rs @@ -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 { + 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 = (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); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69..5426f2d 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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>, ConvertedImage>; + +// Unit tests force collisions through the real cache path; production keeps RandomState. +#[cfg(test)] +type ImageContentCache = HashMap>, ConvertedImage, std::hash::BuildHasherDefault>; +#[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, images: HashMap>, + // 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>, /// 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::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 { + 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, 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) { + 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::("Subtype").is_some_and(|n| n.as_str() == "Image")) + .unwrap(); + (stream.dict().get::("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()); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b03d8db..4d007cd 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -200,3 +200,14 @@ pub fn parse_page_range(s: &str, count: usize) -> Result> { #[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; diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index 8f6a864..cbfbe27 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -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> { 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) -> Option>> { + 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]) -> Option> { @@ -84,6 +135,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option> let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?]; let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let kids: Vec = 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]) -> Option> 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> { ); 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))); + } +} diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs new file mode 100644 index 0000000..dd3d413 --- /dev/null +++ b/crates/pdf/tests/image_content_cache.rs @@ -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; diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/page_entry_search_contract.rs new file mode 100644 index 0000000..9812155 --- /dev/null +++ b/crates/pdf/tests/page_entry_search_contract.rs @@ -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 { + let mut pdf = + format!("%PDF-1.7\n1 0 obj\n<>\nendobj\n2 0 obj\n<>\nendobj\n").into_bytes(); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"\ntrailer\n<>\nstartxref\n0\n%%EOF\n"); + pdf +} +fn old_page() -> &'static [u8] { + b"3 0 obj\n<>\nendobj\n" +} +fn update(pdf: &[u8], extras: &[Option<&str>]) -> Option> { + add_page_entries(pdf, &extras.iter().map(|s| s.map(str::to_owned)).collect::>()) +} +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<>\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<>\nendobj\n3 0 obj\n<>\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<>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<>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<>"); + 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<>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}<>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<>\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<>endobj\r3 0 obj<>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<>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<>endobj IGNORE endobj\n"); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with("3 0 obj<>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<>\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<>endobj\n").as_bytes()); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with(format!("{id} 0 obj<>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, Vec>) { + let count = 4097; + let size = count + 3; + let kids = (3..size).map(|id| format!("{id} 0 R ")).collect::(); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0usize]; + for (id, body) in [(1, "<>".to_owned()), (2, format!("<>"))] { + 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<>\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<>\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<>\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")); +} diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs new file mode 100644 index 0000000..e75a7a8 --- /dev/null +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -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 { + 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, x: f64) { + place_named(d, data, x, &format!("original-{x}.png"), "image/png", 0); +} + +fn place_named(d: &mut Document, data: Vec, 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) -> Vec { + drawn_content(bytes).0 +} + +fn drawn_content(bytes: Vec) -> (Vec, 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, Vec<([u8; 4], kurbo::Rect)>); + impl Device<'_> for Sink { + fn set_soft_mask(&mut self, _: Option>) {} + 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>, _: 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#""#; + 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, + alpha: Option>, +} + +fn image_objects(bytes: &[u8]) -> Vec { + 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::("Subtype").is_some_and(|n| n.as_str() == "Image")) + .map(|s| ImageObject { + space: s.dict().get::("ColorSpace").map_or("ICC".into(), |n| n.as_str().to_owned()), + size: (s.dict().get::("Width").unwrap(), s.dict().get::("Height").unwrap()), + samples: s.decoded().unwrap().into_owned(), + alpha: s.dict().get::>("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::>(), [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 { + 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::>(), [([0, 0, 255], (1, 1)), ([255, 0, 0], (1, 1)), ([255, 0, 0], (2, 1))]); + assert_eq!(drawn.iter().map(|i| i.x).collect::>(), [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}"); +} diff --git a/crates/pdf/tests/support/image_fixtures.rs b/crates/pdf/tests/support/image_fixtures.rs new file mode 100644 index 0000000..02dafae --- /dev/null +++ b/crates/pdf/tests/support/image_fixtures.rs @@ -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, other_inks: bool) -> Vec { + 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::(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::(2, 1).unwrap(); + if other_inks { + image.encoder().write_tag(Tag::Unknown(332), 2u16).unwrap(); + } + image.write_data(&INKS).unwrap(); + } + bytes +}