Merge pull request #3464 from dotCooCoo/fix/pdf-14-writes-16-bit-images-at-8

PDF export: a 16-bit PNG is written at 8 bits for PDF 1.3 and 1.4 instead of failing the export
This commit is contained in:
BFlat
2026-10-22 06:07:26 +07:00
committed by GitHub
4 changed files with 139 additions and 4 deletions
+26
View File
@@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() {
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
}
/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the
/// pages of a .ai file too), and keeps 16 bits from PDF 1.5.
#[test]
fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() {
let mut s = session(1);
let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000]));
let mut png = vec![];
image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap();
let readable = json!({"compressText": false});
for (cmd, p, depth) in [
("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8),
("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8),
("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8),
("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8),
("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16),
("document.exportPdf", json!({"compression": readable}), 16),
] {
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16"));
assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}");
assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]);
}
}
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
/// raster exports give, once.
+56 -3
View File
@@ -569,6 +569,9 @@ pub(crate) struct Exporter<'a> {
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
/// Images may ask viewers to smooth them (not in PDF/A).
interpolate: bool,
/// Images may have 16 bits a sample (PDF 1.5 and later); in earlier files they are written at
/// 8 bits ([`embed`]).
sixteen_bit: bool,
/// How images are resampled and compressed.
compression: &'a crate::CompressionSettings,
/// Layers whose Print option is off are written too.
@@ -629,6 +632,8 @@ impl<'a> Exporter<'a> {
knockout: doc.page_knockout,
lab_spots: vec![],
interpolate: set.standard != Standard::PdfA2b,
// PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]).
sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14),
compression: &set.compression,
non_printing: false,
intent: vectorcraft_color::cms::active().settings().intent,
@@ -1791,7 +1796,7 @@ impl Exporter<'_> {
if let Some(w) = r.warning {
self.warn(w);
}
r.image.or_else(|| embed(blob, interpolate))
r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit))
});
if img.is_none() {
self.warn(UNDECODED);
@@ -1851,11 +1856,41 @@ fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) {
(im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d))
}
/// Does PNG `bytes` have 16 bits a sample: the bit depth of its IHDR chunk, which follows the
/// signature?
fn png_16_bit(bytes: &[u8]) -> bool {
bytes.get(12..16) == Some(b"IHDR".as_slice()) && bytes.get(24) == Some(&16)
}
/// PNG `bytes` at 8 bits a sample: decoded, and encoded again as gray or RGB, with alpha when the
/// source has it, and with the source's ICC profile. `None` when it can't be decoded.
fn png_8_bit(bytes: &[u8]) -> Option<Vec<u8>> {
use image::{ColorType, DynamicImage, ImageDecoder, ImageEncoder};
let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let icc = decoder.icc_profile().ok().flatten();
let img = DynamicImage::from_decoder(decoder).ok()?;
let img = match img.color() {
ColorType::L8 | ColorType::L16 => DynamicImage::ImageLuma8(img.into_luma8()),
ColorType::La8 | ColorType::La16 => DynamicImage::ImageLumaA8(img.into_luma_alpha8()),
ColorType::Rgb8 | ColorType::Rgb16 => DynamicImage::ImageRgb8(img.into_rgb8()),
_ => DynamicImage::ImageRgba8(img.into_rgba8()),
};
let mut png = vec![];
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
if let Some(icc) = icc {
encoder.set_icc_profile(icc).ok()?;
}
img.write_with_encoder(encoder).ok()?;
Some(png)
}
/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged),
/// other formats decoded.
fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option<Image> {
/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a
/// sample is written at 8 bits ([`png_8_bit`]).
fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option<Image> {
let bytes = blob.bytes.as_ref().clone();
let direct = match blob.mime.as_str() {
"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(),
"image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(),
@@ -1893,4 +1928,22 @@ mod tests {
s.finish();
page.finish();
}
#[test]
fn a_16_bit_png_at_8_bits_keeps_its_color_type_and_icc_profile() {
use image::{ImageDecoder, ImageEncoder};
let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GRAY).unwrap().to_vec();
let gray = image::DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(4, 2, |x, y| image::Luma([x as u16 * 20000 + y as u16])));
let mut png = vec![];
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
encoder.set_icc_profile(icc.clone()).unwrap();
gray.write_with_encoder(encoder).unwrap();
assert!(png_16_bit(&png));
let out = png_8_bit(&png).unwrap();
assert!(!png_16_bit(&out));
let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(out.as_slice())).unwrap();
assert_eq!(decoder.icc_profile().unwrap(), Some(icc));
assert_eq!(decoder.color_type(), image::ColorType::L8);
assert_eq!(image::DynamicImage::from_decoder(decoder).unwrap().to_luma8(), gray.to_luma8());
}
}
+55 -1
View File
@@ -1,6 +1,7 @@
//! Image compression and downsampling: images above the threshold resolution are resampled to the
//! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the
//! codecs the writer lacks are written as ZIP with a warning.
//! codecs the writer lacks are written as ZIP with a warning. In files before PDF 1.5, 16-bit
//! images are written at 8 bits.
use std::io::Cursor;
@@ -81,6 +82,59 @@ fn jpeg_zip_and_automatic_compression() {
assert!(!text.contains("/DCTDecode") && text.contains("/SMask"));
}
#[test]
fn sixteen_bit_images_are_written_at_8_bits_before_pdf_1_5() {
let mut clear = colour().to_rgba8();
clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0]));
let clear = DynamicImage::from(clear);
// 16-bit PNGs of RGB, gray and RGBA pixels, and the 8-bit pixels they hold.
let images = [
("RGB", DynamicImage::ImageRgb16(colour().to_rgb16()), colour()),
("gray", DynamicImage::ImageLuma16(colour().grayscale().to_luma16()), colour().grayscale()),
("RGBA", DynamicImage::ImageRgba16(clear.to_rgba16()), clear),
];
for (kind, deep, pixels) in images {
let d = doc(encoded(deep, ImageFormat::Png), "image/png");
// PDF/X-1a and PDF/X-3 files are PDF 1.3. A PDF 1.3 file has no soft masks: the app
// flattens an image with transparency before it is written.
let mut cases = vec![(json!({"compatibility": "1.4"}), 8), (json!({"compatibility": "1.5"}), 16), (json!({"compatibility": "1.7"}), 16)];
if kind != "RGBA" {
cases.extend([(json!({"compatibility": "1.3"}), 8), (json!({"standard": "pdfX3", "compatibility": "1.3"}), 8)]);
}
if kind == "gray" {
cases.push((json!({"standard": "pdfX1a", "compatibility": "1.3"}), 8));
}
for (case, depth) in cases {
let mut settings: PdfSettings = serde_json::from_value(case.clone()).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(&d, &PdfOptions { settings, created: Some(0), ..Default::default() })
.unwrap_or_else(|e| panic!("{kind}, {case}: {e}"));
let text = String::from_utf8_lossy(&r.bytes);
let other = if depth == 8 { 16 } else { 8 };
assert!(
text.contains(&format!("/BitsPerComponent {depth}")) && !text.contains(&format!("/BitsPerComponent {other}")),
"{kind}, {case}: {depth} bits"
);
assert!(!r.warnings.iter().any(|w| w.contains("decoded")), "{kind}, {case}: {:?}", r.warnings);
if depth == 8 {
// The image reads back with the 8-bit pixels (within 1 for the PDF/X-3 file's RGB
// image, which is tagged with the sRGB profile).
let back = import(&r.bytes).unwrap();
let got = image::load_from_memory(&back.images.values().next().unwrap().bytes).unwrap().to_rgba8();
let close = got.dimensions() == (600, 600) && got.as_raw().iter().zip(pixels.to_rgba8().as_raw()).all(|(a, b)| a.abs_diff(*b) <= 1);
assert!(back.images.len() == 1 && close, "{kind}, {case}: the pixels");
}
}
}
// A 16-bit PNG whose pixel data can't be decoded is left out with the note.
let mut cut = encoded(DynamicImage::ImageRgb16(colour().to_rgb16()), ImageFormat::Png);
let at = cut.windows(4).position(|w| w == b"IDAT").unwrap();
cut.truncate(at + 10);
let settings = serde_json::from_value(json!({"compatibility": "1.4"})).unwrap();
let r = export_with_report(&doc(cut, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap();
assert_eq!(r.warnings, ["an image couldn't be decoded and is missing from the picture"]);
}
#[test]
fn codecs_the_writer_lacks_are_zip_with_a_warning() {
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
+2
View File
@@ -615,6 +615,8 @@ Images follow the `compression` settings of their kind (`color`, `gray`, or `mon
`abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed
with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others
lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them.
A 16-bit PNG that is not resampled, recompressed or converted keeps its 16 bits a sample in PDF 1.5 and later files,
and is written at 8 bits in PDF 1.3 and 1.4 files.
CMYK images stay CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged, as in PDF/X-3 and
PDF/X-4): a CMYK JPEG neither resampled nor recompressed is written unchanged, and the others' ink amounts are
resampled and compressed again (CMYK JPEG or ZIP). `output.conversion` converts them like CMYK colours: `destination`