Issue #666: images made into a PDF keep their ICC profile (#729)

Create from images (doc_create, and image_xobject for stamps, signatures and
inserted images) dropped a JPEG's or PNG's embedded ICC profile, so a CMYK
JPEG became untagged /DeviceCMYK.

- JPEG: the APP2 ICC_PROFILE chunks are joined by sequence number; a profile
  with a missing or repeated chunk is ignored.
- PNG: the iCCP profile is read.
- A profile whose header is sound (acsp) and whose colour space matches the
  image (GRAY/RGB/CMYK for 1/3/4 components) becomes [/ICCBased <stream>]
  with /N and the device space as /Alternate. Anything else keeps the device
  space, as before. JPEG data and an Adobe CMYK /Decode are unchanged.
- Images in one document that carry the same profile share one profile
  object.

TIFF profiles are not read yet.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
LloydNicholson
2026-10-10 06:14:51 -04:00
committed by GitHub
co-authored by lloyd Claude Opus 5.5
parent d5b5687f79
commit 06a7b9a7ca
4 changed files with 189 additions and 35 deletions
+3 -1
View File
@@ -6,7 +6,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
- `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution
(PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is
(`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate,
with transparency as a soft mask;
with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
`iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with
the device space as `/Alternate`; images with the same profile share one profile object;
- `from_images_with_resolution(images, ImageResolution::Dpi(dpi))`: override the page
resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi
for one point per pixel, or `ImageResolution::Embedded` for the same behavior as
+84 -32
View File
@@ -72,17 +72,79 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result<Document, CreateEr
// ── images ──────────────────────────────────────────────────────────────────────────────────
/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, and size
/// in pixels and dots per inch.
/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, the ICC
/// profile the file carried (checked against the colour space by `icc_for`), and size in pixels
/// and dots per inch.
struct Embedded {
dict: Dict,
data: Vec<u8>,
filtered: bool,
smask: Option<(Dict, Vec<u8>)>,
icc: Option<Vec<u8>>,
px: (u32, u32),
dpi: (f64, f64),
}
/// `profile` when it can tag an image of `n` colour components: an ICC header (`acsp`) whose
/// colour space is grey, RGB or CMYK to match. Anything else is left out, and the image keeps
/// its device colour space.
fn icc_for(profile: Vec<u8>, n: usize) -> Option<Vec<u8>> {
let space: &[u8] = match n {
1 => b"GRAY",
3 => b"RGB ",
4 => b"CMYK",
_ => return None,
};
(profile.len() >= 128 && profile.get(36..40) == Some(b"acsp") && profile.get(16..20) == Some(space)).then_some(profile)
}
/// A JPEG's ICC profile from its APP2 `ICC_PROFILE` chunks: (sequence number, chunk count,
/// data). `None` unless every chunk from 1 to the count is there exactly once.
fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option<Vec<u8>> {
let count = chunks.first()?.1;
chunks.sort_by_key(|c| c.0);
let complete = usize::from(count) == chunks.len() && chunks.iter().zip(1..=count).all(|(c, seq)| c.0 == seq && c.1 == count);
complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect())
}
/// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space
/// (with the device space as `/Alternate`); `profiles` shares one object between images that
/// carry the same profile.
fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec<u8>, ObjRef)>) -> ObjRef {
let mut d = img.dict;
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
let device = d.name(b"ColorSpace").map(<[u8]>::to_vec);
let n = match device.as_deref() {
Some(b"DeviceGray") => 1,
Some(b"DeviceRGB") => 3,
Some(b"DeviceCMYK") => 4,
_ => 0,
};
if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) {
let r = match profiles.iter().find(|(p, _)| *p == icc) {
Some((_, r)) => *r,
None => {
let mut pd = Dict::new();
pd.set(b"N".to_vec(), Object::Int(n as i64));
pd.set(b"Alternate".to_vec(), Object::Name(device));
let r = doc.add(Object::Stream(Stream::flate(pd, &icc)));
profiles.push((icc, r));
r
}
};
d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(r)]));
}
if let Some((m, alpha)) = img.smask {
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
doc.add(Object::Stream(stream))
}
const JP2_SIGNATURE: &[u8] = &[0, 0, 0, 0x0C, b'j', b'P', b' ', b' ', 0x0D, 0x0A, 0x87, 0x0A];
fn be32(b: &[u8], at: usize) -> Option<u32> {
@@ -156,7 +218,7 @@ fn jpx(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
}
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("JPXDecode"));
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi })
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, icc: None, px: (w, h), dpi })
}
fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
@@ -165,6 +227,7 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
return Err(bad("not a JPEG file"));
}
let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false);
let mut icc_chunks = Vec::new();
while i + 4 <= bytes.len() {
if bytes[i] != 0xFF {
i += 1;
@@ -190,6 +253,12 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
}
}
0xEE if seg.starts_with(b"Adobe") => adobe = true,
// APP2 ICC profile, possibly split over several chunks.
0xE2 => {
if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(|r| r.split_first_chunk::<2>()) {
icc_chunks.push((*seq, *count, data));
}
}
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
if seg.len() < 6 {
return Err(bad("bad frame header"));
@@ -220,7 +289,8 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
d.set(b"ColorSpace".to_vec(), Object::name(cs));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi })
let icc = jpeg_icc(icc_chunks);
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, icc, px: (w, h), dpi })
}
fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
@@ -232,6 +302,7 @@ fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
Some(png::PixelDimensions { xppu, yppu, unit: png::Unit::Meter }) if xppu > 0 && yppu > 0 => (xppu as f64 * 0.0254, yppu as f64 * 0.0254),
_ => (72.0, 72.0),
};
let icc = reader.info().icc_profile.as_ref().map(|p| p.to_vec());
let mut buf = vec![0; reader.output_buffer_size().ok_or_else(|| bad("image too large".into()))?];
let frame = reader.next_frame(&mut buf).map_err(|e| bad(e.to_string()))?;
let (w, h) = (frame.width, frame.height);
@@ -266,7 +337,7 @@ fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
(m, alpha)
});
Ok(Embedded { dict: d, data: colour, filtered: false, smask, px: (w, h), dpi })
Ok(Embedded { dict: d, data: colour, filtered: false, smask, icc, px: (w, h), dpi })
}
/// 8-bit RGBA pixels → an image (gray when every pixel is, with a soft mask when any pixel is
@@ -292,7 +363,7 @@ fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded {
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
(m, rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect())
});
Embedded { dict: d, data: colour, filtered: false, smask, px: (w, h), dpi }
Embedded { dict: d, data: colour, filtered: false, smask, icc: None, px: (w, h), dpi }
}
/// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read:
@@ -346,25 +417,25 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
Embedded { dict: d, data, filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
C::Gray(8 | 16) => {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
Embedded { dict: d, data, filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
C::RGB(8 | 16) => {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
Embedded { dict: d, data, filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
C::CMYK(8 | 16) => {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceCMYK"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
Embedded { dict: d, data, filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
C::RGBA(8 | 16) => rgba_image(&data, (w, h), dpi),
C::GrayA(8 | 16) => {
@@ -445,17 +516,7 @@ fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> {
let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1);
let mut d = img.dict;
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
if let Some((m, alpha)) = img.smask {
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
Ok((doc.add(Object::Stream(stream)), size))
Ok((add_image(doc, img, &mut Vec::new()), size))
}
/// Resolution used to size PDF pages. Image pixels are never resampled.
@@ -484,6 +545,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
return Err(CreateError::Invalid("no images".into()));
}
let mut doc = Document::new_empty();
let mut profiles = Vec::new();
for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
let dpi = match resolution {
ImageResolution::Embedded => img.dpi,
@@ -494,17 +556,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
let k = (MAX_SIDE / w.max(h)).min(1.0);
w *= k;
h *= k;
let mut d = img.dict;
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
if let Some((m, alpha)) = img.smask {
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
let xr = doc.add(Object::Stream(stream));
let xr = add_image(&mut doc, img, &mut profiles);
let mut xobj = Dict::new();
xobj.set(b"Im0".to_vec(), Object::Ref(xr));
let mut res = Dict::new();
+100
View File
@@ -303,3 +303,103 @@ fn source_kind_tells_pdfs_images_and_text_apart() {
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
assert_eq!(source_kind("", b""), None);
}
/// A synthetic ICC profile: a 128-byte header (size, device class, colour space, `acsp`) and
/// some payload. Only the header is read.
fn icc_profile(space: &[u8; 4]) -> Vec<u8> {
let mut p = vec![0u8; 300];
p[..4].copy_from_slice(&300u32.to_be_bytes());
p[12..16].copy_from_slice(b"prtr");
p[16..20].copy_from_slice(space);
p[36..40].copy_from_slice(b"acsp");
for (i, b) in p.iter_mut().enumerate().skip(128) {
*b = i as u8;
}
p
}
/// A minimal CMYK JPEG header (SOI, Adobe APP14, `profile` in two APP2 ICC_PROFILE chunks,
/// SOF0 3×2 with four components, EOI).
fn cmyk_jpeg_bytes(profile: &[u8]) -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, 2]);
let (a, b) = profile.split_at(profile.len() / 2);
// Written out of order: the sequence numbers decide.
for (seq, part) in [(2u8, b), (1, a)] {
v.extend_from_slice(&[0xFF, 0xE2]);
v.extend_from_slice(&(2 + 14 + part.len() as u16).to_be_bytes());
v.extend_from_slice(b"ICC_PROFILE\0");
v.extend_from_slice(&[seq, 2]);
v.extend_from_slice(part);
}
v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8, 0, 2, 0, 3, 4, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 4, 0x11, 0]);
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
/// The ICC profile behind an image's colour space: (`/N`, `/Alternate`, profile bytes, object).
fn icc_of(doc: &Document, image: &Dict) -> Option<(i64, Vec<u8>, Vec<u8>, Object)> {
let cs = image.get(b"ColorSpace")?.as_array()?.clone();
assert_eq!(cs.first().and_then(Object::as_name), Some(&b"ICCBased"[..]));
let r = cs.get(1)?.clone();
let Object::Stream(s) = &*doc.resolve(&r) else { panic!("the ICC profile is not a stream") };
Some((s.dict.int(b"N")?, s.dict.name(b"Alternate")?.to_vec(), s.decoded().unwrap(), r))
}
#[test]
fn embedded_icc_profiles_tag_the_image_colour_space() {
// A CMYK JPEG keeps its profile as /ICCBased (N 4), its data byte for byte and its Decode.
let cmyk = icc_profile(b"CMYK");
let jpeg = cmyk_jpeg_bytes(&cmyk);
let rgb = icc_profile(b"RGB ");
let png = {
let mut out = Vec::new();
let mut info = png::Info::with_size(2, 1);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Eight;
info.icc_profile = Some(rgb.clone().into());
let mut w = png::Encoder::with_info(&mut out, info).unwrap().write_header().unwrap();
w.write_image_data(&[10, 20, 30, 40, 50, 60]).unwrap();
w.finish().unwrap();
out
};
let doc = reopen(&from_images(&[("a.jpg".into(), jpeg.clone()), ("b.jpg".into(), jpeg.clone()), ("c.png".into(), png)]).unwrap());
let (n, alt, data, first) = icc_of(&doc, &image_of(&doc, 0)).expect("the CMYK JPEG is ICC-tagged");
assert_eq!((n, alt.as_slice(), data), (4, &b"DeviceCMYK"[..], cmyk.clone()));
assert!(image_of(&doc, 0).contains(b"Decode"), "Adobe CMYK stays inverted");
let p = &pages(&doc)[0];
let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!() };
assert_eq!(*s.raw, jpeg, "the JPEG data is embedded as is");
let (_, _, _, second) = icc_of(&doc, &image_of(&doc, 1)).unwrap();
assert_eq!(first, second, "images with the same profile share one profile object");
// A PNG iCCP profile tags an RGB image (N 3).
let (n, alt, data, _) = icc_of(&doc, &image_of(&doc, 2)).expect("the PNG is ICC-tagged");
assert_eq!((n, alt.as_slice(), data), (3, &b"DeviceRGB"[..], rgb.clone()));
// The colour space is still exported as CMYK / RGB.
let out = extract_images(&doc, &[0, 1, 2], 0);
assert_eq!(out.images.len(), 3, "{:?}", out.skipped);
// A profile for another colour space, a damaged one or a missing chunk: the device space.
let mut no_acsp = cmyk.clone();
no_acsp[36] = b'x';
let missing_chunk = {
let mut v = cmyk_jpeg_bytes(&cmyk);
// Drop the first APP2 segment (sequence 2 of 2).
let at = v.windows(2).position(|w| w == [0xFF, 0xE2]).unwrap();
let len = u16::from_be_bytes([v[at + 2], v[at + 3]]) as usize;
v.drain(at..at + 2 + len);
v
};
for (what, bytes) in [("an RGB profile", cmyk_jpeg_bytes(&rgb)), ("no acsp", cmyk_jpeg_bytes(&no_acsp)), ("a missing chunk", missing_chunk)] {
let doc = reopen(&from_images(&[("x.jpg".into(), bytes)]).unwrap());
assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceCMYK"[..]), "{what}");
}
// image_xobject (stamps, signatures, inserted images) tags its image the same way.
let mut doc = Document::new_empty();
let (r, _) = image_xobject(&mut doc, "a.jpg", &jpeg).unwrap();
let Object::Stream(s) = &*doc.get(r) else { panic!() };
assert_eq!(icc_of(&doc, &s.dict).map(|(n, ..)| n), Some(4));
}
+2 -2
View File
@@ -6696,8 +6696,8 @@ milestone = "M10"
status = "shipped"
commands = ["create.images"]
tools = ["doc_create"]
evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"]
notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution)"
evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space"]
notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet"
[[feature]]
id = "create.from-multipage-tiff"