Files
vectorcraft/crates/pdf/src/tests_compression.rs
T
dotCooCoo 5f6c7b8da9 PDF export: a 16-bit PNG is written at 8 bits for PDF 1.3 and 1.4 instead of failing the export
When the Compression settings keep a PNG as it is, the PNG goes to
the PDF writer unchanged, and the writer refuses images of 16 bits a
sample below PDF 1.5. Saving a document with a placed 16-bit PNG as
PDF 1.4, or as a .ai file at 1.4, failed with "PDF writer error:
SixteenBitImage(...)" and wrote nothing. PDF 1.3 and PDF/X-3 failed
the same way for an opaque 16-bit PNG, and PDF/X-1a failed for a
16-bit gray PNG.

In PDF 1.3 and 1.4 files, such a PNG is now decoded and encoded again
at 8 bits a sample, with the same color type and ICC profile, before
the writer reads it. From PDF 1.5 on, it keeps its 16 bits. A 16-bit
PNG whose pixel data can't be decoded is left out with the note "an
image couldn't be decoded and is missing from the picture".

Docs: docs/mcp.md (PDF options).
2026-10-22 05:57:11 +07:00

153 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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. In files before PDF 1.5, 16-bit
//! images are written at 8 bits.
use std::io::Cursor;
use image::{DynamicImage, ImageFormat};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use crate::*;
fn encoded(img: DynamicImage, format: ImageFormat) -> Vec<u8> {
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), format).unwrap();
out
}
/// A 600 × 600 colour photo-like image.
fn colour() -> DynamicImage {
image::RgbImage::from_fn(600, 600, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x * y) % 251) as u8])).into()
}
/// A document with image `bytes` (600 × 600 pixels) placed one inch wide: 600 ppi.
fn doc(bytes: Vec<u8>, mime: &str) -> Document {
let mut d = Document::new(100.0, 100.0);
d.images.insert("img".into(), ImageBlob::new(mime, bytes));
let xf = Affine::scale(72.0 / 600.0);
let n = Node::new(
d.alloc_id(),
NodeKind::Image(Box::new(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() })),
);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The PDF of `d` with Compression options `compression` (content streams left readable) and its
/// warnings.
fn pdf(d: &Document, compression: Value) -> (String, Vec<String>) {
let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap();
(String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings)
}
#[test]
fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
let d = doc(encoded(colour(), ImageFormat::Png), "image/png");
for method in ["average", "subsample", "bicubic"] {
let (text, warnings) = pdf(&d, json!({"color": {"downsample": method, "ppi": 150, "abovePpi": 225}}));
assert!(text.contains("/Width 150") && text.contains("/Height 150"), "{method}");
assert!(warnings.is_empty(), "{warnings:?}");
}
// Below the threshold, or without downsampling, the image keeps its pixels.
for c in [json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 700}}), json!({})] {
assert!(pdf(&d, c.clone()).0.contains("/Width 600"), "{c}");
}
// Greyscale images take the greyscale settings.
let grey = doc(encoded(colour().grayscale(), ImageFormat::Png), "image/png");
let (text, _) = pdf(&grey, json!({"color": {"downsample": "bicubic", "ppi": 150}, "gray": {"downsample": "bicubic", "ppi": 100}}));
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
}
#[test]
fn jpeg_zip_and_automatic_compression() {
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
let jpeg = doc(encoded(colour(), ImageFormat::Jpeg), "image/jpeg");
let dct = |d: &Document, c: Value| pdf(d, c).0.contains("/DCTDecode");
assert!(dct(&png, json!({"color": {"compression": "jpeg", "quality": "low"}})), "JPEG compression");
assert!(!dct(&png, json!({"color": {"compression": "zip"}})) && pdf(&png, json!({})).0.contains("/FlateDecode"), "ZIP");
assert!(!dct(&png, json!({})), "automatic: lossless for a PNG");
assert!(dct(&jpeg, json!({})) && dct(&jpeg, json!({"color": {"downsample": "average", "ppi": 150}})), "automatic: a JPEG stays JPEG");
assert!(!dct(&jpeg, json!({"color": {"compression": "zip"}})), "ZIP re-encodes a JPEG losslessly");
// JPEG has no transparency: an image with some stays lossless.
let mut clear = colour().to_rgba8();
clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0]));
let clear = doc(encoded(clear.into(), ImageFormat::Png), "image/png");
let (text, _) = pdf(&clear, json!({"color": {"compression": "jpeg"}}));
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");
let (text, warnings) = pdf(&png, json!({"color": {"compression": "jpeg2000"}}));
assert!(!text.contains("/JPXDecode") && text.contains("/FlateDecode"));
assert!(warnings.len() == 1 && warnings[0].contains("JPEG 2000"), "{warnings:?}");
// Black-and-white images are monochrome: their settings apply, CCITT becomes ZIP.
let bw = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 7 + y / 5) % 2 == 0 { 0 } else { 255 }]));
let bw = doc(encoded(bw.into(), ImageFormat::Png), "image/png");
let (text, warnings) = pdf(&bw, json!({"mono": {"downsample": "bicubic", "ppi": 300, "abovePpi": 450, "compression": "ccittG4"}}));
assert!(text.contains("/Width 300") && !text.contains("/CCITTFaxDecode"));
assert!(warnings.len() == 1 && warnings[0].contains("CCITT"), "{warnings:?}");
// The codecs of other kinds of images say nothing.
assert!(pdf(&png, json!({"mono": {"compression": "ccittG4"}})).1.is_empty());
}