PDF export: PDF/X-1a writes tagged gray images in DeviceGray

PDF/X-1a allows device colors only, but a gray TIFF that carried a gray
ICC profile kept it when no color conversion applied, and the export
then failed its PDF/X check. A gray or RGB PNG or JPEG with a profile
went the same way through the PDF writer. In PDF/X-1a, images are now
written without the profile they carry; the other standards and plain
exports keep it.
This commit is contained in:
dotCooCoo
2026-11-14 06:02:55 -08:00
parent b73de03cfd
commit e45c6f518e
4 changed files with 55 additions and 20 deletions
+19 -9
View File
@@ -138,6 +138,7 @@ impl Exporter<'_> {
}
let doc = self.doc;
let out = self.out.clone();
let profiles = !out.standard.cmyk_only();
let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| {
let cmyk_image = blob.is_cmyk();
if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk {
@@ -146,12 +147,12 @@ impl Exporter<'_> {
let r = if cmyk_image {
crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None)
} else {
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels())
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles)
};
if let Some(w) = r.warning {
self.warn(w);
}
r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit))
r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles))
});
if img.is_none() {
self.warn(UNDECODED);
@@ -318,10 +319,12 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option<Vec<u8>> {
const MAX_EMBED_PIXELS: u64 = 1 << 27;
/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged),
/// other formats (a TIFF) decoded with the ICC profile they carry and their 16-bit samples
/// ([`crate::images::decoded_image`]). Without `sixteen_bit` (a file before PDF 1.5), a PNG or
/// another image of 16 bits a sample is written at 8 bits ([`png_8_bit`]).
pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option<Image> {
/// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in
/// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an
/// image of any format that carries a gray or RGB profile is decoded too, and written without it.
/// Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of 16 bits a sample is
/// written at 8 bits ([`png_8_bit`]).
pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option<Image> {
// The header's size first: the writer's decoders allocate what it declares, and the encoded
// bytes of an image that is far larger than any the app reads may be a few kilobytes.
let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions();
@@ -329,7 +332,14 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee
return None;
}
let bytes = blob.bytes.as_ref().clone();
let direct = match blob.mime.as_str() {
// The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, such an image is
// decoded and written without it.
let tagged = || {
let space = crate::images::source_icc(&bytes).and_then(|icc| vectorcraft_doc::cmyk::icc_color_space(&icc));
matches!(space.as_ref(), Some(b"GRAY" | b"RGB "))
};
let mime = if !profiles && tagged() { "" } else { blob.mime.as_str() };
let direct = match mime {
"image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()),
"image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(),
"image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(),
@@ -337,7 +347,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee
"image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(),
_ => None,
};
direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit))
direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit, profiles))
}
#[cfg(test)]
@@ -356,7 +366,7 @@ mod tests {
let crc =
!png[12..29].iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }));
png[29..33].copy_from_slice(&crc.to_be_bytes());
assert!(super::embed(&ImageBlob::png(png), false, true).is_none());
assert!(super::embed(&ImageBlob::png(png), false, true, true).is_none());
}
#[test]
+1 -1
View File
@@ -325,7 +325,7 @@ impl Exporter<'_> {
let img = match self.images.get(&cache) {
Some(img) => img.clone(),
None => {
let img = embed(&image.blob, self.interpolate, self.sixteen_bit);
let img = embed(&image.blob, self.interpolate, self.sixteen_bit, !self.out.standard.cmyk_only());
self.images.insert(cache, img.clone());
img
}
+19 -9
View File
@@ -78,7 +78,7 @@ fn icc_for(icc: Option<Vec<u8>>, space: ImageColorspace) -> Option<Arc<Vec<u8>>>
/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself,
/// which reads tags of any size), another format's as its decoder reads it. `None` without one.
fn source_icc(bytes: &[u8]) -> Option<Vec<u8>> {
pub(crate) fn source_icc(bytes: &[u8]) -> Option<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
if reader.format() == Some(ImageFormat::Tiff) {
return vectorcraft_doc::cmyk::tiff_icc_profile(bytes);
@@ -140,10 +140,11 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option<Vec<u8>>, interpolate:
Image::from_custom(raw, interpolate).ok()
}
/// Image `bytes` decoded within [`limits`] with the ICC profile it carries ([`source_icc`]), as
/// [`from_decoded`] writes it. `None` when it can't be decoded.
pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool) -> Option<Image> {
from_decoded(decoded(bytes)?, source_icc(bytes), interpolate, sixteen_bit)
/// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when
/// `profiles` allows it (not in PDF/X-1a, which has device colours only), as [`from_decoded`]
/// writes it. `None` when it can't be decoded.
pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option<Image> {
from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit)
}
/// Are all of the RGBA pixels `rgba` grey?
@@ -304,9 +305,17 @@ fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage {
/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask:
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours
/// converted by `convert` (colour images). `interpolate` lets viewers smooth it. An image that
/// can't be decoded is kept as it is.
pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>) -> Recoded {
/// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile
/// they carry when `profiles` allows it (not in PDF/X-1a, which has device colours only).
/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is.
pub(crate) fn recode(
bytes: &[u8],
size: (f64, f64),
c: &CompressionSettings,
interpolate: bool,
convert: Option<&Pixels>,
profiles: bool,
) -> Recoded {
let keep = Recoded { image: None, warning: None };
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
@@ -338,7 +347,8 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
// Pixels that keep their colours keep the profile they are in; converted ones are in the
// destination's.
let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" };
let icc = convert.is_none().then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want));
let icc =
(profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want));
let rgb_icc = icc.is_some() && kind == Kind::Color;
let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8();
if kind == Kind::Mono {
+16 -1
View File
@@ -234,7 +234,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() {
assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None);
assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all");
let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap();
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None);
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true);
assert!(r.image.is_none(), "kept as it is");
}
@@ -391,4 +391,19 @@ fn tagged_tiffs_keep_their_icc_profile() {
let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}}));
assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}");
}
// PDF/X-1a has device colours only: a tagged gray TIFF or PNG is written in DeviceGray, kept as
// it is, resampled or compressed with JPEG.
let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})];
let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png");
for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png)] {
for compression in &compressions {
let mut settings: PdfSettings =
serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() });
let spaces = image_spaces(&r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes);
let gray = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none();
assert!(gray, "PDF/X-1a, {format}, {compression}: {spaces:?}");
}
}
}