Compare commits
3 Commits
main
...
land/pr-17
| Author | SHA1 | Date |
|---|---|---|
|
|
8ed7d7482a | |
|
|
20c8c095ff | |
|
|
abf9a40dc0 |
|
|
@ -159,9 +159,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)]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
@ -320,6 +323,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,
|
||||
|
|
@ -403,6 +409,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,
|
||||
|
|
@ -411,6 +443,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.
|
||||
|
|
@ -1191,7 +1228,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 mut mode = RasterMode::for_standard(self.opts.standard);
|
||||
mode.interpolate &= self.interpolate;
|
||||
let img = match fmt {
|
||||
|
|
@ -1201,10 +1250,8 @@ impl Exporter<'_> {
|
|||
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, mode) {
|
||||
Some(img) => Some(img),
|
||||
None => {
|
||||
self.warn(format!(
|
||||
"{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)",
|
||||
asset.name
|
||||
));
|
||||
warning = Some(CMYK_FALLBACK_WARNING);
|
||||
self.warn(format!("{}: {CMYK_FALLBACK_WARNING}", asset.name));
|
||||
lossless(&data, fmt, mode)
|
||||
}
|
||||
},
|
||||
|
|
@ -1214,6 +1261,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
|
||||
}
|
||||
|
|
@ -1479,3 +1531,218 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
|
|||
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
|
||||
jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
|
||||
}
|
||||
|
||||
#[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,
|
||||
interpolate: true,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,3 +202,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;
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
//! 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, 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_is_skipped() {
|
||||
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 would fail later in the backend: the
|
||||
// image is decoded first, so both copies are skipped with a warning, not a failed export.
|
||||
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 report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
assert!(report.warnings.iter().any(|w| w.contains("could not be decoded")), "{:?}", report.warnings);
|
||||
assert!(image_objects(&report.bytes).is_empty());
|
||||
}
|
||||
|
||||
#[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_export_as_pdfa_without_interpolation() {
|
||||
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);
|
||||
// PDF/A turns image interpolation off (#128), and the cached conversion keeps that.
|
||||
let report =
|
||||
export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(image_objects(&report.bytes).len(), 1, "both placements share one image");
|
||||
let needle = b"/Interpolate true";
|
||||
assert!(!report.bytes.windows(needle.len()).any(|w| w == needle));
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue