mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user