mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 08:45:25 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user