diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 7438c6fd3..6f4730729 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -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. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0f8a561a3..3997d5ec2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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> { + 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 { +/// 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 { 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()); + } } diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index 2954f91c3..dd25a98eb 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -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"); diff --git a/docs/mcp.md b/docs/mcp.md index 4c6bf9adb..0d810c4cf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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`