diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69b9..095ea77cc 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -154,6 +154,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: archival.is_some(), + interpolate: archival.is_none(), tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, @@ -327,6 +328,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: false, + interpolate: true, tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, @@ -421,6 +423,8 @@ pub(crate) struct Exporter<'a> { pub reverse_cmaps: HashMap>>, /// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet). pub rgb_only: bool, + /// Ask viewers to smooth upscaled images (`/Interpolate`). Off for PDF/A, which forbids it. + interpolate: bool, /// Tagged PDF: the structure in reading order (stories gather their frames' content). tags: Vec, story_tags: HashMap>, @@ -1168,21 +1172,21 @@ impl Exporter<'_> { let data = asset.data.clone(); let fmt = image::guess_format(&data).ok(); let img = match fmt { - Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(), + Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(), // A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print // as four-colour black). PDF/A exports are RGB only. - Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) { + Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) { Some(img) => Some(img), None => { self.warn(format!( "{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)", asset.name )); - lossless(&data, fmt) + lossless(&data, fmt, self.interpolate) } }, - _ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)), - _ => lossless(&data, fmt), + _ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)), + _ => lossless(&data, fmt, self.interpolate), }; if img.is_none() { self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); @@ -1276,11 +1280,11 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { Some(out) } -fn lossless(data: &Arc>, fmt: Option) -> Option { +fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option { let direct = match fmt { - Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(), - Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), true).ok(), - Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), true).ok(), + Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(), _ => None, }; direct.or_else(|| { @@ -1320,18 +1324,18 @@ impl krilla::image::CustomImage for CmykImage { } /// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK. -fn cmyk_tiff(data: &[u8]) -> Option { +fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option { let r = designcraft_images::decode_cmyk_tiff(data)?; // krilla panics when the channel lengths don't match the size. let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?; if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) { return None; } - Image::from_custom(CmykImage(Arc::new(r)), true).ok() + Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok() } /// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -fn recompress(data: &[u8]) -> Option { +fn recompress(data: &[u8], interpolate: bool) -> Option { let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -1340,5 +1344,5 @@ fn recompress(data: &[u8]) -> Option { let mut buf = Vec::new(); let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90); image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; - Image::from_jpeg(buf.into(), true).ok() + Image::from_jpeg(buf.into(), interpolate).ok() } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1771c4e4f..83744a3e1 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -271,6 +271,55 @@ fn cmyk_tiff_keeps_its_inks() { } } +/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner. +fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec { + let img = + image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 128, if alpha && x == 0 && y == 0 { 0 } else { 255 }])); + let img = if fmt == image::ImageFormat::Jpeg { + image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(img).to_rgb8()) + } else { + image::DynamicImage::ImageRgba8(img) + }; + let mut b = std::io::Cursor::new(Vec::new()); + img.write_to(&mut b, fmt).unwrap(); + b.into_inner() +} + +/// Each image XObject's /Interpolate value (`None` when absent). +fn interpolate_flags(bytes: &[u8]) -> Vec> { + use hayro_syntax::object::Name; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + 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| s.dict().get::("Interpolate")) + .collect() +} + +/// PDF/A-2b rejected placed PNG, JPEG, GIF and WebP images (#71): they were written with +/// /Interpolate true, which PDF/A forbids. Ordinary exports keep interpolation. +#[test] +fn archival_exports_keep_placed_images_without_interpolation() { + use image::ImageFormat as F; + let mut d = doc_with_text("x"); + for (fmt, alpha) in [(F::Png, true), (F::Png, false), (F::Jpeg, false), (F::Gif, true), (F::WebP, true)] { + place(&mut d, raster(fmt, alpha), (8, 8)); + } + for compress_images in [false, true] { + let opts = PdfOptions { compress_images, created: Some(1_700_000_000), ..Default::default() }; + let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..opts.clone() }); + let a = a.unwrap_or_else(|e| panic!("PDF/A-2b with images (compress {compress_images}): {e:?}")); + let flags = interpolate_flags(&a.bytes); + assert!(flags.len() >= 5, "every placed image is in the PDF/A file: {flags:?}"); + assert!(flags.iter().all(|f| *f != Some(true)), "{flags:?}"); + let plain = export_pdf(&d, &Cache::new(), &opts).unwrap(); + let flags = interpolate_flags(&plain); + assert!(flags.len() >= 5, "{flags:?}"); + assert!(flags.contains(&Some(true)), "ordinary exports interpolate: {flags:?}"); + } +} + /// What a page draws: each glyph (its text and outline bounds) and each filled path's bounds. fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { use hayro_interpret::font::Glyph;