mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge pull request #7811 from dotCooCoo/fix/cmyk-images-from-pdf-or-eps-lose-their-source
PDF import: CMYK images keep the profile of their ICCBased color space
This commit is contained in:
+155
-4
@@ -618,13 +618,25 @@ pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> {
|
||||
icc.get(16..20)?.try_into().ok()
|
||||
}
|
||||
|
||||
/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one.
|
||||
/// The most bytes of an ICC profile a TIFF's tag 34675 is read with (16 MiB).
|
||||
const MAX_TIFF_ICC: u64 = 16 << 20;
|
||||
|
||||
/// The ICC profile of TIFF tag 34675 (ICCProfile, of type UNDEFINED or BYTE) of the image `d`
|
||||
/// reads, as its bytes; `None` without one and for one over [`MAX_TIFF_ICC`] bytes.
|
||||
fn tiff_icc(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> Option<Vec<u8>> {
|
||||
d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty())
|
||||
use tiff::tags::{Tag, Type, ValueBuffer};
|
||||
let mut ifd = d.image_ifd();
|
||||
let entry = ifd.find_entry(Tag::IccProfile)?;
|
||||
if !matches!(entry.field_type(), Type::UNDEFINED | Type::BYTE) || entry.count() == 0 || entry.count() > MAX_TIFF_ICC {
|
||||
return None;
|
||||
}
|
||||
let mut buf = ValueBuffer::empty(Type::UNDEFINED);
|
||||
ifd.find_tag_buf(Tag::IccProfile, &mut buf).ok()??;
|
||||
Some(buf.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own
|
||||
/// limits, whatever the size of the image; `None` for other images and TIFFs without one.
|
||||
/// The ICC profile of TIFF `bytes`' first image (tag 34675), up to [`MAX_TIFF_ICC`] bytes whatever
|
||||
/// the size of the image; `None` for other images, TIFFs without one and a longer one.
|
||||
pub fn tiff_icc_profile(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let (_, mut d) = tiff(bytes)?;
|
||||
tiff_icc(&mut d)
|
||||
@@ -638,6 +650,61 @@ fn jpeg_icc(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
d.icc_profile().filter(|icc| !icc.is_empty())
|
||||
}
|
||||
|
||||
/// The bytes after an `ICC_PROFILE` APP2 segment's length: its name, then the segment's number and
|
||||
/// the count of segments.
|
||||
const ICC_SEGMENT: &[u8; 12] = b"ICC_PROFILE\0";
|
||||
|
||||
/// JPEG `bytes` carrying `icc` in place of the ICC profile they carry, if any: every
|
||||
/// `ICC_PROFILE` APP2 segment before the scan taken out, `icc` written as numbered APP2 segments
|
||||
/// after the leading application segments, and the other segments and the scan as they are. `None`
|
||||
/// for bytes whose segments before the scan can't be read, an empty `icc`, and one longer than 255
|
||||
/// segments hold.
|
||||
pub fn jpeg_with_icc(bytes: &[u8], icc: &[u8]) -> Option<Vec<u8>> {
|
||||
// A segment's length counts itself (2 bytes) and the name, number and count (14).
|
||||
const CHUNK: usize = 0xFFFF - 2 - ICC_SEGMENT.len() - 2;
|
||||
let chunks: Vec<&[u8]> = icc.chunks(CHUNK).collect();
|
||||
let count = u8::try_from(chunks.len()).ok().filter(|n| *n > 0)?;
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
return None;
|
||||
}
|
||||
let mut segments = Vec::with_capacity(icc.len() + 18 * chunks.len());
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let len = u16::try_from(chunk.len() + 2 + ICC_SEGMENT.len() + 2).ok()?;
|
||||
segments.extend_from_slice(&[0xFF, 0xE2]);
|
||||
segments.extend_from_slice(&len.to_be_bytes());
|
||||
segments.extend_from_slice(ICC_SEGMENT);
|
||||
segments.extend_from_slice(&[u8::try_from(i + 1).ok()?, count]);
|
||||
segments.extend_from_slice(chunk);
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() + segments.len());
|
||||
out.extend_from_slice(&[0xFF, 0xD8]);
|
||||
let mut icc_segments = Some(segments);
|
||||
let mut at = 2;
|
||||
// The segments before the scan (SOS), each with its length; a marker without one ends them.
|
||||
// Fill bytes (0xFF) before a marker are left out.
|
||||
loop {
|
||||
while bytes.get(at..at + 2) == Some(&[0xFF, 0xFF]) {
|
||||
at += 1;
|
||||
}
|
||||
let (Some(&0xFF), Some(&marker)) = (bytes.get(at), bytes.get(at + 1)) else { return None };
|
||||
let app = (0xE0..=0xEF).contains(&marker);
|
||||
if !app && let Some(s) = icc_segments.take() {
|
||||
out.extend_from_slice(&s);
|
||||
}
|
||||
if matches!(marker, 0xDA | 0xD8 | 0xD9 | 0x01 | 0xD0..=0xD7) {
|
||||
break;
|
||||
}
|
||||
let len = usize::from(u16::from_be_bytes(bytes.get(at + 2..at + 4)?.try_into().ok()?));
|
||||
let segment = bytes.get(at..(at + 2).checked_add(len)?).filter(|_| len >= 2)?;
|
||||
if !(marker == 0xE2 && segment.get(4..16) == Some(ICC_SEGMENT.as_slice())) {
|
||||
out.extend_from_slice(segment);
|
||||
}
|
||||
at += segment.len();
|
||||
}
|
||||
out.extend_from_slice(bytes.get(at..)?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in.
|
||||
fn cmyk_icc(icc: Option<Vec<u8>>) -> Option<Vec<u8>> {
|
||||
icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK"))
|
||||
@@ -746,6 +813,14 @@ impl ImageBlob {
|
||||
Some(Self::new("image/tiff", tiff))
|
||||
}
|
||||
|
||||
/// [`ImageBlob::cmyk_tiff`], carrying `icc` as tag 34675 (ICCProfile) when it is a profile of
|
||||
/// CMYK colors; any other profile is left out. The caller checks the rest of the profile.
|
||||
pub fn cmyk_tiff_with_icc(inks: &Inks, icc: Option<&[u8]>) -> Option<Self> {
|
||||
let icc = icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK"));
|
||||
let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), None, icc)?;
|
||||
Some(Self::new("image/tiff", tiff))
|
||||
}
|
||||
|
||||
/// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its
|
||||
/// header.
|
||||
pub fn is_cmyk(&self) -> bool {
|
||||
@@ -1968,6 +2043,35 @@ mod tests {
|
||||
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
|
||||
}
|
||||
|
||||
/// A CMYK TIFF written with a CMYK profile carries it byte for byte as tag 34675; a profile of
|
||||
/// other colors, and none, leave the TIFF untagged. The inks are the same either way.
|
||||
#[test]
|
||||
fn a_cmyk_tiff_carries_the_cmyk_profile_it_is_given() {
|
||||
let profile = |space: &[u8; 4]| {
|
||||
let mut icc = vec![0u8; 128];
|
||||
icc[0..4].copy_from_slice(&128u32.to_be_bytes());
|
||||
icc[16..20].copy_from_slice(space);
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
icc
|
||||
};
|
||||
let cmyk = profile(b"CMYK");
|
||||
let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&cmyk)).unwrap();
|
||||
assert_eq!(b.cmyk_icc_profile(), Some(cmyk), "the profile, byte for byte");
|
||||
assert_eq!(b.cmyk(), Some(inks()));
|
||||
// Read back up to 16 MiB; a longer one isn't read.
|
||||
for (len, read) in [(16 << 20, true), ((16 << 20) + 1, false)] {
|
||||
let mut big = profile(b"CMYK");
|
||||
big.resize(len, 0);
|
||||
let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&big)).unwrap();
|
||||
assert_eq!(b.cmyk_icc_profile().is_some_and(|p| p == big), read, "{len} bytes");
|
||||
}
|
||||
for icc in [Some(profile(b"RGB ")), None] {
|
||||
let b = ImageBlob::cmyk_tiff_with_icc(&inks(), icc.as_deref()).unwrap();
|
||||
assert_eq!(tiff_icc_profile(&b.bytes), None, "{icc:?}");
|
||||
assert_eq!(b.cmyk(), Some(inks()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_gives_the_size_without_the_samples() {
|
||||
assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2)));
|
||||
@@ -2031,6 +2135,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK JPEG given a profile carries it in place of its own, its samples as they were; a
|
||||
/// profile longer than 255 segments hold, an empty one and bytes that aren't a JPEG give none.
|
||||
#[test]
|
||||
fn a_cmyk_jpeg_carries_the_profile_it_is_given() {
|
||||
let profile = |len: usize, mark: u8| {
|
||||
let mut icc = vec![mark; len];
|
||||
icc[16..20].copy_from_slice(b"CMYK");
|
||||
icc
|
||||
};
|
||||
let px: Vec<u8> = (0..16 * 16).flat_map(|_| [10u8, 40, 100, 20]).collect();
|
||||
let mut jpg = Vec::new();
|
||||
let mut e = jpeg_encoder::Encoder::new(&mut jpg, 100);
|
||||
e.add_icc_profile(&profile(300_000, 1)).unwrap();
|
||||
e.encode(&px, 16, 16, jpeg_encoder::ColorType::Cmyk).unwrap();
|
||||
let inks = ImageBlob::new("image/jpeg", jpg.clone()).cmyk().unwrap();
|
||||
// One segment holds 65,519 bytes of a profile.
|
||||
for len in [128, 65_519, 65_520, 300_000, 1 << 20] {
|
||||
let icc = profile(len, 2);
|
||||
let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&jpg, &icc).unwrap());
|
||||
assert!(b.cmyk_icc_profile() == Some(icc), "{len} bytes");
|
||||
assert!(b.cmyk() == Some(inks.clone()), "{len} bytes: the samples as they were");
|
||||
}
|
||||
// A profile segment after the quantization table is taken out too.
|
||||
let dqt = jpg.windows(2).position(|w| w == [0xFF, 0xDB]).unwrap();
|
||||
let after = dqt + 2 + usize::from(u16::from_be_bytes([jpg[dqt + 2], jpg[dqt + 3]]));
|
||||
let mut late = jpg.clone();
|
||||
let stale = profile(128, 3);
|
||||
let segment = [&[0xFF, 0xE2, 0, 144][..], ICC_SEGMENT, &[1, 1], &stale].concat();
|
||||
late.splice(after..after, segment);
|
||||
let icc = profile(128, 2);
|
||||
let b = jpeg_with_icc(&late, &icc).unwrap();
|
||||
assert_eq!(b.windows(ICC_SEGMENT.len()).filter(|w| *w == ICC_SEGMENT).count(), 1, "one profile segment");
|
||||
assert!(ImageBlob::new("image/jpeg", b).cmyk_icc_profile() == Some(icc));
|
||||
// Fill bytes before the quantization table's marker are passed over.
|
||||
let mut filled = jpg.clone();
|
||||
filled.splice(dqt..dqt, [0xFF; 3]);
|
||||
assert!(ImageBlob::new("image/jpeg", filled.clone()).cmyk() == Some(inks.clone()), "the decoder reads it");
|
||||
let icc = profile(128, 2);
|
||||
let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&filled, &icc).unwrap());
|
||||
assert!(b.cmyk_icc_profile() == Some(icc), "fill bytes");
|
||||
assert!(b.cmyk() == Some(inks.clone()), "fill bytes: the samples as they were");
|
||||
assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519, 2)).is_some(), "255 segments");
|
||||
assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519 + 1, 2)).is_none(), "256 segments");
|
||||
assert!(jpeg_with_icc(&jpg, &[]).is_none(), "empty");
|
||||
assert!(jpeg_with_icc(&ImageBlob::cmyk_tiff(&inks).unwrap().bytes, &profile(128, 2)).is_none(), "a TIFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_images_are_not_cmyk() {
|
||||
let mut png = Vec::new();
|
||||
|
||||
@@ -87,8 +87,8 @@ fn icc_for(icc: Option<Vec<u8>>, space: ImageColorspace) -> Option<Arc<Vec<u8>>>
|
||||
icc.filter(|icc| valid_icc(icc, want)).map(Arc::new)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, up
|
||||
/// to 16 MiB), another format's as its decoder reads it. `None` without one.
|
||||
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) {
|
||||
@@ -183,8 +183,8 @@ fn lossless(
|
||||
/// it holds is still written.
|
||||
pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27;
|
||||
|
||||
/// The most bytes an ICC profile carried by an image is written with (16 MiB).
|
||||
const MAX_ICC: usize = 16 << 20;
|
||||
/// The most bytes an ICC profile carried by an image is written or read with (16 MiB).
|
||||
pub(crate) const MAX_ICC: usize = 16 << 20;
|
||||
|
||||
/// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a
|
||||
/// CMYK image can be tagged with ([`valid_icc`]). `None` for other images and other profiles.
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! four components. A JPEG whose Decode array inverts its samples (Adobe's CMYK JPEGs, the
|
||||
//! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any
|
||||
//! other is decoded, its Decode array applied, and stored as a CMYK TIFF
|
||||
//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer
|
||||
//! ([`ImageBlob::cmyk_tiff`]). An ICC-based image keeps the CMYK profile its color space holds
|
||||
//! ([`icc_profile`]): the TIFF carries it, and a kept JPEG carries it in place of its own
|
||||
//! ([`jpeg_with_icc`]), or, when its segments can't hold it, is stored as a CMYK TIFF carrying it.
|
||||
//! A mask (`/SMask`, `/Mask`) doesn't change that: the importer
|
||||
//! carries it as the image's opacity mask ([`has_mask`]). A DeviceN image of process plates only
|
||||
//! keeps them the same way, as a CMYK image ([`process_devicen`]). An image of one ink (a
|
||||
//! Separation, or a DeviceN of one colorant) keeps it: its tints are read as a grayscale image
|
||||
@@ -12,8 +15,9 @@ use craft_pdf::syntax::Filter;
|
||||
use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams};
|
||||
use craft_pdf::syntax::object::{Array, Dict, Name, Stream};
|
||||
use vectorcraft_doc::ImageBlob;
|
||||
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
|
||||
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components, jpeg_with_icc};
|
||||
|
||||
use crate::images::{MAX_ICC, valid_icc};
|
||||
use crate::import_color::ink_names;
|
||||
|
||||
/// A CMYK image read as RGB: why.
|
||||
@@ -42,6 +46,24 @@ fn cmyk_space(d: &Dict<'_>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile of the image's four-component `ICCBased` color space, when it is a CMYK profile an
|
||||
/// image can be tagged with ([`valid_icc`], at most [`MAX_ICC`] bytes). `None` for other color
|
||||
/// spaces and other profiles: the image then keeps its samples untagged.
|
||||
fn icc_profile(d: &Dict<'_>) -> Option<Vec<u8>> {
|
||||
let a = d.get::<Array<'_>>(key(d, b"ColorSpace", b"CS"))?;
|
||||
let mut it = a.flex_iter();
|
||||
if it.next::<Name<'_>>()?.as_ref() != b"ICCBased" {
|
||||
return None;
|
||||
}
|
||||
let s = it.next::<Stream<'_>>()?;
|
||||
if s.dict().get::<u8>(b"N") != Some(4) {
|
||||
return None;
|
||||
}
|
||||
// One byte over the limit, so a longer profile reads as too long rather than cut short.
|
||||
let icc = s.decoded_within(MAX_ICC + 1).ok()?;
|
||||
valid_icc(&icc, *b"CMYK").then(|| icc.into_owned())
|
||||
}
|
||||
|
||||
/// Each component's Decode range (`[0 1]` each when the array is missing or malformed).
|
||||
fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] {
|
||||
let mut out = [(0.0, 1.0); 4];
|
||||
@@ -63,11 +85,25 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
|
||||
return process_devicen(st, width, height);
|
||||
}
|
||||
let ranges = decode_ranges(d);
|
||||
let icc = icc_profile(d);
|
||||
let filters = st.filters();
|
||||
if ranges == INVERTED && matches!(filters.as_slice(), [Filter::DctDecode]) {
|
||||
let raw = st.raw_data();
|
||||
if jpeg_components(&raw) == Some(4) {
|
||||
return Ok(Some(ImageBlob::new("image/jpeg", raw.into_owned())));
|
||||
let jpeg = ImageBlob::new("image/jpeg", raw.into_owned());
|
||||
// The color space's profile describes the samples, not one the JPEG carries: the JPEG
|
||||
// carries it in place of its own, or, when its segments can't hold it, its inks go in a
|
||||
// TIFF carrying it. A JPEG that neither can be done to stays as it is.
|
||||
if let Some(icc) = icc.as_deref().filter(|icc| jpeg.cmyk_icc_profile().as_deref() != Some(*icc)) {
|
||||
let tagged = jpeg_with_icc(&jpeg.bytes, icc)
|
||||
.map(|bytes| ImageBlob::new("image/jpeg", bytes))
|
||||
.filter(|b| b.cmyk_icc_profile().as_deref() == Some(icc))
|
||||
.or_else(|| jpeg.cmyk().and_then(|i| ImageBlob::cmyk_tiff_with_icc(&i, Some(icc))));
|
||||
if let Some(tagged) = tagged {
|
||||
return Ok(Some(tagged));
|
||||
}
|
||||
}
|
||||
return Ok(Some(jpeg));
|
||||
}
|
||||
}
|
||||
let bpc = d.get::<u8>(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8);
|
||||
@@ -91,7 +127,7 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
|
||||
.chunks_exact(size * 4)
|
||||
.flat_map(|px| px.chunks_exact(size).zip(ranges).map(|(s, (lo, hi))| ((lo + value(s) * (hi - lo)).clamp(0.0, 1.0) * 255.0).round() as u8))
|
||||
.collect();
|
||||
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD)
|
||||
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff_with_icc(&i, icc.as_deref())).map(Some).ok_or(UNREAD)
|
||||
}
|
||||
|
||||
/// An image in a spot ink read as RGB: why.
|
||||
|
||||
@@ -176,9 +176,17 @@ fn a_cmyk_jpeg_passes_through_open_and_every_pdf_export() {
|
||||
assert_eq!(w[0].decode, [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0], "{settings}");
|
||||
assert!(w[0].data == jpg, "{settings}: the JPEG data unchanged");
|
||||
assert!(out.warnings.iter().all(|w| !w.contains("image")), "{settings}: {:?}", out.warnings);
|
||||
// And open again: the same JPEG.
|
||||
// And open again: the same JPEG, carrying the profile it was written in when it was
|
||||
// ICC-based.
|
||||
let back = import(&out.bytes).unwrap();
|
||||
assert_eq!(blobs(&back)[0].bytes.as_slice(), jpg.as_slice(), "{settings}");
|
||||
let blob = blobs(&back)[0];
|
||||
if icc_n.is_some() {
|
||||
assert_eq!(blob.mime, "image/jpeg", "{settings}");
|
||||
assert_eq!(blob.cmyk(), b[0].cmyk(), "{settings}: the same samples");
|
||||
assert_eq!(blob.cmyk_icc_profile(), w[0].icc, "{settings}: the profile it was written in");
|
||||
} else {
|
||||
assert_eq!(blob.bytes.as_slice(), jpg.as_slice(), "{settings}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +677,125 @@ fn a_cmyk_image_with_a_malformed_profile_is_written_untagged() {
|
||||
assert_eq!((w.len(), w[0].space.as_str()), (1, "DeviceCMYK"), "{w:?}");
|
||||
}
|
||||
|
||||
/// An ICC-based CMYK image opens with the profile its color space holds, byte for byte, beside its
|
||||
/// ink amounts: stored as a CMYK TIFF carrying it whatever its Decode array and depth, and a CMYK
|
||||
/// JPEG kept with its samples as they are, carrying that profile in place of its own. An export
|
||||
/// with source profiles writes the image in that profile. DeviceCMYK and process-plate DeviceN
|
||||
/// images have no profile of their own and stay untagged.
|
||||
#[test]
|
||||
fn an_icc_based_cmyk_image_opens_with_its_profile() {
|
||||
let ink = [0u8, 102, 255, 0];
|
||||
let inks: Vec<u8> = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat();
|
||||
let z = |data: &[u8]| crate::output::deflate(data).unwrap();
|
||||
let inverted: Vec<u8> = inks.iter().map(|v| 255 - v).collect();
|
||||
let wide: Vec<u8> = inks.iter().flat_map(|v| (*v as u16 * 257).to_be_bytes()).collect();
|
||||
let icc = cmyk_profile(|_| {});
|
||||
let other = cmyk_profile(|i| i[152] = b'X');
|
||||
let jpg = |icc: Option<&[u8]>| match icc {
|
||||
Some(icc) => cmyk_jpeg_with(&flat(ink, 4), 2, 2, icc),
|
||||
None => cmyk_jpeg(&flat(ink, 4), 2, 2),
|
||||
};
|
||||
let jpeg_inks = ImageBlob::new("image/jpeg", jpg(None)).cmyk().unwrap().data;
|
||||
let iccbased = |more: &str| cmyk_dict(2, 2, more).replace("/DeviceCMYK", "[/ICCBased @icc 0 R]");
|
||||
// At most five images fit the page. The profile and a type 4 function follow them.
|
||||
let open = |images: &[(String, Vec<u8>)]| {
|
||||
let n = images.len();
|
||||
let images: Vec<(String, Vec<u8>)> =
|
||||
images.iter().map(|(d, data)| (d.replace("@icc", &(5 + n).to_string()).replace("@fn", &(6 + n).to_string()), data.clone())).collect();
|
||||
let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ }");
|
||||
let r = import_with_report(&pdf_with_images(&images, &[stream("/N 4 /Filter /FlateDecode", &z(&icc)), function]), &ImportOptions::default())
|
||||
.unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert_eq!(blobs(&r.document).len(), n);
|
||||
r.document
|
||||
};
|
||||
let tiffs = open(&[
|
||||
(iccbased("/Filter /FlateDecode"), z(&inks)),
|
||||
(iccbased(&format!("/Filter /FlateDecode {INVERTED}")), z(&inverted)),
|
||||
(iccbased("/Filter /FlateDecode").replace("/BitsPerComponent 8", "/BitsPerComponent 16"), z(&wide)),
|
||||
(cmyk_dict(2, 2, "/Filter /FlateDecode"), z(&inks)),
|
||||
(cmyk_dict(2, 2, "/Filter /FlateDecode").replace("/DeviceCMYK", "[/DeviceN [/Cyan /Magenta /Yellow /Black] /DeviceCMYK @fn 0 R]"), z(&inks)),
|
||||
]);
|
||||
for (i, blob) in blobs(&tiffs).iter().enumerate() {
|
||||
let profile = (i < 3).then_some(icc.as_slice());
|
||||
assert_eq!(
|
||||
(blob.mime.as_str(), blob.cmyk().unwrap().data, blob.cmyk_icc_profile().as_deref()),
|
||||
("image/tiff", inks.clone(), profile),
|
||||
"image {i}"
|
||||
);
|
||||
}
|
||||
// A JPEG without a profile, with the same one and with another.
|
||||
let jpegs = open(&[
|
||||
(iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(None)),
|
||||
(iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(Some(&icc))),
|
||||
(iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(Some(&other))),
|
||||
]);
|
||||
for (i, blob) in blobs(&jpegs).iter().enumerate() {
|
||||
assert_eq!(
|
||||
(blob.mime.as_str(), blob.cmyk().unwrap().data, blob.cmyk_icc_profile().as_deref()),
|
||||
("image/jpeg", jpeg_inks.clone(), Some(icc.as_slice())),
|
||||
"image {i}"
|
||||
);
|
||||
}
|
||||
assert!(*blobs(&jpegs)[1].bytes == jpg(Some(&icc)), "the JPEG carrying the same profile, as it is");
|
||||
|
||||
// Images written the same way are written once: some are tagged, and only the TIFFs have
|
||||
// untagged ones (the DeviceCMYK and DeviceN images, in the document's profile).
|
||||
for (d, untagged) in [(tiffs, true), (jpegs, false)] {
|
||||
let out = export_with_report(&d, &opts(json!({"output": {"profiles": "all"}}))).unwrap();
|
||||
let w: Vec<Written> = written(&out.bytes).into_iter().filter(|w| w.width == 2).collect();
|
||||
let tagged = w.iter().filter(|w| w.icc.as_deref() == Some(icc.as_slice())).count();
|
||||
assert!(
|
||||
tagged > 0 && (w.len() > tagged) == untagged,
|
||||
"{tagged} of {} tagged: {:?}",
|
||||
w.len(),
|
||||
w.iter().map(|w| (&w.space, &w.filter, w.icc.as_ref().map(|i| i.len()))).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile an image can't be tagged with (malformed, of RGB colors, or over 16 MiB) leaves the
|
||||
/// ICC-based image's inks as they are, untagged and without a warning; a profile of 16 MiB exactly
|
||||
/// is kept.
|
||||
#[test]
|
||||
fn an_icc_based_cmyk_image_with_a_profile_it_cant_carry_opens_untagged() {
|
||||
let inks: Vec<u8> = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat();
|
||||
let z = |data: &[u8]| crate::output::deflate(data).unwrap();
|
||||
let sized = |len: usize| {
|
||||
cmyk_profile(|i| {
|
||||
i.resize(len, 0);
|
||||
i[0..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes());
|
||||
})
|
||||
};
|
||||
let limit = crate::images::MAX_ICC;
|
||||
let jpeg = cmyk_jpeg(&flat([0, 102, 255, 0], 4), 2, 2);
|
||||
for (what, icc, kept) in [
|
||||
("signature", cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx")), false),
|
||||
("RGB color space", cmyk_profile(|i| i[16..20].copy_from_slice(b"RGB ")), false),
|
||||
("declared size", cmyk_profile(|i| i[0..4].copy_from_slice(&200u32.to_be_bytes())), false),
|
||||
("empty", vec![], false),
|
||||
("over 16 MiB", sized(limit + 1), false),
|
||||
("16 MiB", sized(limit), true),
|
||||
] {
|
||||
let profile = stream("/N 4 /Filter /FlateDecode", &z(&icc));
|
||||
let images = [
|
||||
(cmyk_dict(2, 2, "/Filter /FlateDecode").replace("/DeviceCMYK", "[/ICCBased 7 0 R]"), z(&inks)),
|
||||
(cmyk_dict(2, 2, &format!("/Filter /DCTDecode {INVERTED}")).replace("/DeviceCMYK", "[/ICCBased 7 0 R]"), jpeg.clone()),
|
||||
];
|
||||
let r = import_with_report(&pdf_with_images(&images, &[profile]), &ImportOptions::default()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings);
|
||||
let b = blobs(&r.document);
|
||||
assert_eq!(b.len(), 2, "{what}");
|
||||
assert_eq!((b[0].mime.as_str(), b[0].cmyk().unwrap().data), ("image/tiff", inks.clone()), "{what}: the inks as they were");
|
||||
assert_eq!(b[0].cmyk_icc_profile().is_some_and(|p| p == icc), kept, "{what}");
|
||||
if kept {
|
||||
assert_eq!(b[1].cmyk_icc_profile().is_some_and(|p| p == icc), kept, "{what}");
|
||||
} else {
|
||||
assert!(b[1].mime == "image/jpeg" && *b[1].bytes == jpeg, "{what}: the JPEG as it is");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid
|
||||
/// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none).
|
||||
fn export_masked(mask: Vec<u8>, clip: bool, invert: bool) -> (Vec<u8>, Vec<Option<Vec<u8>>>) {
|
||||
|
||||
@@ -1909,6 +1909,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`).
|
||||
- A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF.
|
||||
- PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`).
|
||||
- PDF import keeps the CMYK profile of an image in a four-component `ICCBased` color space, whose stream holds the profile ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)), byte for byte with the image's ink amounts, so PDF export can write the image in it again. A CMYK TIFF carries it as tag 34675, whatever the image's Decode array and bit depth ([ICC.1:2010, Annex B](https://www.color.org/specification/ICC1v43_2010-12.pdf)). A CMYK JPEG kept as it is carries it in `ICC_PROFILE` APP2 segments in place of a profile of its own, with its compressed samples unchanged, and one whose segments can't hold it is stored as a CMYK TIFF carrying it. A profile that isn't well formed, isn't of CMYK colors or is over 16 MiB (VectorCraft's own limit) leaves the image's inks as they are, untagged. DeviceCMYK and DeviceN images carry no profile, and a CMYK TIFF's tag 34675 is read up to 16 MiB. EPS and PostScript import does not keep one: PostScript defines no `ICCBased` profile stream (`crates/pdf/src/tests/cmykimages.rs`, `cmyk::tests`).
|
||||
- An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`).
|
||||
- PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, a profile whose color space differs from the samples', and a malformed profile, a PNG's or JPEG's kept as it is included, are written without it; such a JPEG keeps its JPEG data. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, a JPEG kept as JPEG with its JPEG data unchanged, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`).
|
||||
- PDF export with Output › Convert to Destination and an sRGB destination converts a gray or RGB image that carries an ICC profile from that profile to sRGB, with the color settings' rendering intent, and writes it without the profile, as the conversion applies to every color ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)): 8-bit and 16-bit TIFFs (16 bits a sample from PDF 1.5 on when not resampled or recompressed), PNGs, JPEGs (kept as JPEG) and resampled images. A JPEG in an sRGB profile keeps its JPEG data, a black-and-white image whose converted levels are gray is written as grayscale, and without conversion every image keeps its profile. An image whose profile the color engine can't read, or whose pixels can't be decoded, is not converted: it keeps its profile, with a warning (`crates/pdf/src/tests/compression.rs`).
|
||||
|
||||
Reference in New Issue
Block a user