diff --git a/crates/create/README.md b/crates/create/README.md index 22e0355d5c..cd20f1959c 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -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 diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 0276896e9c..6e325ef69a 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -72,17 +72,79 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result, filtered: bool, smask: Option<(Dict, Vec)>, + icc: Option>, 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, n: usize) -> Option> { + 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> { + 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, 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 { @@ -156,7 +218,7 @@ fn jpx(name: &str, bytes: &[u8]) -> Result { } 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 { @@ -165,6 +227,7 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result { 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 { } } 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 { 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 { @@ -232,6 +302,7 @@ fn png_image(name: &str, bytes: &[u8]) -> Result { 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 { 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, 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, 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)], 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::, _>>()?.into_iter().flatten() { let dpi = match resolution { ImageResolution::Embedded => img.dpi, @@ -494,17 +556,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec)], 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(); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 447f3304e5..e649fdf73e 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -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 { + 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 { + 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, Vec, 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)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e8513972df..7a9be21a3c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"