PDF: PDF/A exports keep placed PNG, JPEG, GIF and WebP images by writing them without interpolation (#71)

This commit is contained in:
aiongg
2026-10-08 01:51:30 -04:00
parent f4a4c9709b
commit fa22e61f83
2 changed files with 66 additions and 13 deletions
+17 -13
View File
@@ -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<u32, Arc<HashMap<u32, char>>>,
/// 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<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
@@ -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<Vec<u8>> {
Some(out)
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>) -> Option<Image> {
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, interpolate: bool) -> Option<Image> {
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<Image> {
fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option<Image> {
let r = designcraft_images::decode_cmyk_tiff(data)?;
// krilla panics when the channel lengths don't match the size.
let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?;
if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) {
return None;
}
Image::from_custom(CmykImage(Arc::new(r)), 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<Image> {
fn recompress(data: &[u8], interpolate: bool) -> Option<Image> {
let img = image::load_from_memory(data).ok()?;
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
@@ -1340,5 +1344,5 @@ fn recompress(data: &[u8]) -> Option<Image> {
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
Image::from_jpeg(buf.into(), true).ok()
Image::from_jpeg(buf.into(), interpolate).ok()
}
+49
View File
@@ -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<u8> {
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<Option<bool>> {
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::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| s.dict().get::<bool>("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<kurbo::Rect>) {
use hayro_interpret::font::Glyph;