PDF export: a stored gray or RGB image keeps its ICC profile

A gray or RGB TIFF that reached PDF export as a TIFF, for example in a
native document or from a script, was decoded to 8-bit RGBA and written
as DeviceRGB, which dropped the ICC profile of its tag 34675, its 16-bit
samples and its gray color space. It is now written in an ICCBased color
space holding that profile, as a tagged PNG is: gray stays gray, 16-bit
samples stay 16-bit from PDF 1.5 on, and the alpha becomes a soft mask
only when some pixel is not opaque.

A gray or RGB image that the Compression settings resample or compress
with JPEG keeps its source profile too, a PNG included. Pixels that the
Output settings convert are written without it, and so is a profile whose
color space differs from the samples'. CMYK images are unchanged.
This commit is contained in:
dotCooCoo
2026-11-14 02:42:43 -08:00
parent edb228ac33
commit 7e66a990bc
4 changed files with 256 additions and 27 deletions
+4 -7
View File
@@ -318,8 +318,9 @@ 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 decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a
/// sample is written at 8 bits ([`png_8_bit`]).
/// 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> {
// 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.
@@ -336,11 +337,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(|| {
let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8();
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit))
}
#[cfg(test)]
+95 -19
View File
@@ -26,7 +26,8 @@ use vectorcraft_doc::cmyk::jpeg_components;
use crate::output::{CmykPixels, Pixels};
use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec};
/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel.
/// Raw pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel, of 8 bits a
/// sample or of 16 (big-endian), with the ICC profile the colour samples are in, if any.
#[derive(Clone, Hash)]
struct Raw {
color: Arc<Vec<u8>>,
@@ -34,6 +35,8 @@ struct Raw {
width: u32,
height: u32,
space: ImageColorspace,
bits: BitsPerComponent,
icc: Option<Arc<Vec<u8>>>,
}
impl CustomImage for Raw {
@@ -46,7 +49,7 @@ impl CustomImage for Raw {
}
fn bits_per_component(&self) -> BitsPerComponent {
BitsPerComponent::Eight
self.bits
}
fn size(&self) -> (u32, u32) {
@@ -54,7 +57,7 @@ impl CustomImage for Raw {
}
fn icc_profile(&self) -> Option<&[u8]> {
None
self.icc.as_deref().map(Vec::as_slice)
}
fn color_space(&self) -> ImageColorspace {
@@ -62,24 +65,85 @@ impl CustomImage for Raw {
}
}
/// `icc` when it is a profile of the colour space `space` names: `GRAY` for grey samples, `RGB `
/// for RGB ones. `None` for other profiles and spaces.
fn icc_for(icc: Option<Vec<u8>>, space: ImageColorspace) -> Option<Arc<Vec<u8>>> {
let want = match space {
ImageColorspace::Luma => *b"GRAY",
ImageColorspace::Rgb => *b"RGB ",
ImageColorspace::Cmyk | ImageColorspace::DeviceN => return None,
};
icc.filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(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.
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);
}
let mut decoder = reader.into_decoder().ok()?;
decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty())
}
/// A `width` × `height` image of straight (not premultiplied) RGBA pixels, written as RGB (as
/// CMYK when `pixels` converts to CMYK), or as grey when every pixel is grey, with an alpha
/// channel only when some pixel isn't opaque. `interpolate` asks viewers to smooth it when
/// enlarged (PDF/A forbids it). `None` when `rgba` doesn't hold that many pixels.
fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option<Image> {
/// channel only when some pixel isn't opaque. With an RGB `icc` profile, pixels that `pixels`
/// doesn't convert stay RGB and carry it; a grey one tags grey pixels. `interpolate` asks viewers
/// to smooth it when enlarged (PDF/A forbids it). `None` when `rgba` doesn't hold that many pixels.
fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>, icc: Option<Vec<u8>>) -> Option<Image> {
let px = rgba.as_chunks::<4>().0;
if px.len() as u64 != width as u64 * height as u64 || px.is_empty() {
return None;
}
let (color, space) = if neutral(rgba) {
let rgb_icc = pixels.is_none() && icc.as_deref().and_then(vectorcraft_doc::cmyk::icc_color_space) == Some(*b"RGB ");
let (color, space) = if neutral(rgba) && !rgb_icc {
(px.iter().map(|p| p[0]).collect(), ImageColorspace::Luma)
} else if let Some(cmyk) = pixels.and_then(|p| p.cmyk(rgba)) {
(cmyk, ImageColorspace::Cmyk)
} else {
(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb)
};
let icc = icc_for(icc.filter(|_| pixels.is_none()), space);
let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect()));
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok()
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space, bits: BitsPerComponent::Eight, icc }, interpolate).ok()
}
/// Decoded image `img` written losslessly as it is: grey or RGB samples (other kinds as RGB), with
/// an alpha channel only when some pixel isn't opaque, at 16 bits a sample when it has more than 8
/// and `sixteen_bit` allows it (PDF 1.5 and later), else at 8; tagged with ICC profile `icc` when
/// that is a profile of the samples' colour space (`GRAY` or `RGB `). `None` when it has no pixels.
pub(crate) fn from_decoded(img: DynamicImage, icc: Option<Vec<u8>>, interpolate: bool, sixteen_bit: bool) -> Option<Image> {
let (width, height) = (img.width(), img.height());
if width == 0 || height == 0 {
return None;
}
let kind = img.color();
let space = if kind.has_color() { ImageColorspace::Rgb } else { ImageColorspace::Luma };
let n = if kind.has_color() { 3 } else { 1 };
let deep = sixteen_bit && kind.bits_per_pixel() > 8 * u16::from(kind.channel_count());
let (color, alpha, bits) = if deep {
let s = if kind.has_color() { img.into_rgba16().into_raw() } else { img.into_luma_alpha16().into_raw() };
let px = s.chunks_exact(n + 1);
let color = px.clone().flat_map(|p| p[..n].iter().flat_map(|v| v.to_be_bytes())).collect();
let alpha = (kind.has_alpha() && px.clone().any(|p| p[n] < u16::MAX)).then(|| px.flat_map(|p| p[n].to_be_bytes()).collect());
(color, alpha, BitsPerComponent::Sixteen)
} else {
let s = if kind.has_color() { img.into_rgba8().into_raw() } else { img.into_luma_alpha8().into_raw() };
let px = s.chunks_exact(n + 1);
let color = px.clone().flat_map(|p| p[..n].iter().copied()).collect();
let alpha = (kind.has_alpha() && px.clone().any(|p| p[n] < u8::MAX)).then(|| px.map(|p| p[n]).collect());
(color, alpha, BitsPerComponent::Eight)
};
let raw = Raw { color: Arc::new(color), alpha: alpha.map(Arc::new), width, height, space, bits, icc: icc_for(icc, space) };
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)
}
/// Are all of the RGBA pixels `rgba` grey?
@@ -90,7 +154,8 @@ fn neutral(rgba: &[u8]) -> bool {
/// Colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) with an optional `alpha`
/// channel, written losslessly.
fn lossless(samples: Vec<u8>, alpha: Option<Vec<u8>>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option<Image> {
Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space }, interpolate).ok()
let raw = Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space, bits: BitsPerComponent::Eight, icc: None };
Image::from_custom(raw, interpolate).ok()
}
/// [`from_rgba`] of pixels converted by `pixels` (when given).
@@ -98,7 +163,7 @@ pub(crate) fn from_rgba_in(mut rgba: Vec<u8>, width: u32, height: u32, interpola
if let Some(p) = pixels {
p.rgb_in_place(&mut rgba);
}
from_rgba(&rgba, width, height, interpolate, pixels)
from_rgba(&rgba, width, height, interpolate, pixels, None)
}
/// A `width` × `height` image of CMYK ink amounts `inks` (four bytes a pixel), converted by
@@ -270,6 +335,11 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
return Recoded { image: None, warning: rule.instead };
}
let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep };
// 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 rgb_icc = icc.is_some() && kind == Kind::Color;
let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8();
if kind == Kind::Mono {
// Resampling greys the edges: back to black and white.
@@ -284,16 +354,18 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
let (width, height) = rgba.dimensions();
let opaque = rgba.pixels().all(|p| p[3] == 255);
let jpeg = jpeg_quality(&rule, jpeg).filter(|_| opaque).and_then(|q| {
// Grey pixels stay grey; colour ones go to CMYK when the output converts to CMYK.
let grey = kind != Kind::Color || neutral(&rgba);
// Grey pixels stay grey, unless they keep an RGB profile; colour ones go to CMYK when the
// output converts to CMYK.
let grey = kind != Kind::Color || (neutral(&rgba) && !rgb_icc);
let (samples, space) = match convert.filter(|_| !grey).and_then(|p| p.cmyk(&rgba)) {
Some(cmyk) => (cmyk, ImageColorspace::Cmyk),
None if grey => (rgba.pixels().map(|p| p[0]).collect(), ImageColorspace::Luma),
None => (rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb),
};
to_jpeg(&samples, space, width, height, q, interpolate)
let icc = icc_for(icc.clone(), space);
to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice))
});
let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert));
let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc));
Recoded { image, warning: rule.instead }
}
@@ -360,7 +432,7 @@ pub(crate) fn recode_cmyk(
None => (inks, ImageColorspace::Cmyk),
};
// JPEG needs opaque pixels.
let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate));
let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, None));
let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate));
Recoded { image, warning: rule.instead }
}
@@ -405,9 +477,9 @@ pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool)
}
/// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) as a JPEG at
/// `quality`. CMYK JPEGs hold Adobe's inverted samples, which the PDF writer's Decode array
/// undoes.
fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool) -> Option<Image> {
/// `quality`, carrying grey or RGB samples' ICC profile `icc` when given. CMYK JPEGs hold Adobe's
/// inverted samples, which the PDF writer's Decode array undoes.
fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool, icc: Option<&[u8]>) -> Option<Image> {
let mut out = Vec::new();
match space {
ImageColorspace::Cmyk => {
@@ -416,7 +488,11 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual
}
ImageColorspace::Rgb | ImageColorspace::Luma => {
let t = if space == ImageColorspace::Rgb { ExtendedColorType::Rgb8 } else { ExtendedColorType::L8 };
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode(samples, width, height, t).ok()?;
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
if let Some(icc) = icc {
image::ImageEncoder::set_icc_profile(&mut encoder, icc.to_vec()).ok()?;
}
encoder.encode(samples, width, height, t).ok()?;
}
ImageColorspace::DeviceN => return None,
}
+156 -1
View File
@@ -5,7 +5,7 @@
use std::io::Cursor;
use craft_pdf::syntax::object::{Name, Object};
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
use image::{DynamicImage, ImageFormat};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
@@ -237,3 +237,158 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() {
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None);
assert!(r.image.is_none(), "kept as it is");
}
/// A version 2.1 display ICC profile header for colour space `space` (`RGB ` or `GRAY`), `len`
/// bytes in all, every byte after the header different from its neighbors.
fn icc_profile(space: &[u8; 4], len: usize) -> Vec<u8> {
let mut icc: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes());
icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]);
icc[12..16].copy_from_slice(b"mntr");
icc[16..20].copy_from_slice(space);
icc[36..40].copy_from_slice(b"acsp");
icc
}
/// `img` encoded as `format` carrying ICC profile `icc`.
fn tagged(img: &DynamicImage, icc: &[u8], format: ImageFormat) -> Vec<u8> {
use image::ImageEncoder as _;
let mut out = Vec::new();
match format {
ImageFormat::Tiff => {
let mut enc = image::codecs::tiff::TiffEncoder::new(Cursor::new(&mut out));
enc.set_icc_profile(icc.to_vec()).unwrap();
img.write_with_encoder(enc).unwrap();
}
_ => {
let mut enc = image::codecs::png::PngEncoder::new(&mut out);
enc.set_icc_profile(icc.to_vec()).unwrap();
img.write_with_encoder(enc).unwrap();
}
}
out
}
/// An image XObject as it is written.
#[derive(Debug, PartialEq)]
struct Written {
/// Its colour space's name: `ICCBased` for an ICC-based one.
space: String,
/// The profile an ICC-based colour space holds.
icc: Option<Vec<u8>>,
bits: u8,
filter: String,
smask: bool,
}
/// Each image XObject of `pdf` other than soft masks.
fn image_spaces(pdf: &[u8]) -> Vec<Written> {
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
let images: Vec<Stream<'_>> = file
.objects()
.into_iter()
.filter_map(|o| match o {
Object::Stream(s) if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s),
_ => None,
})
.collect();
let masks: Vec<Vec<u8>> = images.iter().filter_map(|s| s.dict().get::<Stream<'_>>(b"SMask")).map(|m| m.raw_data().to_vec()).collect();
let mut out = vec![];
for s in images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())) {
let d = s.dict();
let (space, icc) = match (d.get::<Name<'_>>(b"ColorSpace"), d.get::<Array<'_>>(b"ColorSpace")) {
(Some(n), _) => (n.as_str().to_string(), None),
(_, Some(a)) => {
let mut it = a.flex_iter();
let name = it.next::<Name<'_>>().unwrap().as_str().to_string();
(name, it.next::<Stream<'_>>().map(|s| s.decoded().unwrap().into_owned()))
}
_ => (String::new(), None),
};
let filter = d.get::<Name<'_>>(b"Filter").map(|n| n.as_str().to_string()).unwrap_or_default();
let (bits, smask) = (d.get::<u8>(b"BitsPerComponent").unwrap_or(0), d.get::<Stream<'_>>(b"SMask").is_some());
out.push(Written { space, icc, bits, filter, smask });
}
out
}
/// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based
/// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as
/// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels
/// the Output settings convert, and a profile of another colour space, are written without it.
#[test]
fn tagged_tiffs_keep_their_icc_profile() {
let (rgb, gray) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300));
let mut clear = colour().to_rgba8();
clear.put_pixel(0, 0, image::Rgba([10, 20, 30, 0]));
let tiff = |img: &DynamicImage, icc: &[u8]| doc(tagged(img, icc, ImageFormat::Tiff), "image/tiff");
let export = |d: &Document, settings: Value| {
let mut settings: PdfSettings = serde_json::from_value(settings).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
image_spaces(&r.bytes)
};
let icc_based = |icc: &[u8], bits: u8, filter: &str, smask: bool| {
vec![Written { space: "ICCBased".into(), icc: Some(icc.to_vec()), bits, filter: filter.into(), smask }]
};
let flate = "FlateDecode";
let cases = [
("RGB", tiff(&colour(), &rgb), json!({}), icc_based(&rgb, 8, flate, false)),
("gray", tiff(&colour().grayscale(), &gray), json!({}), icc_based(&gray, 8, flate, false)),
(
"RGB, 16 bits",
tiff(&DynamicImage::ImageRgb16(colour().to_rgb16()), &rgb),
json!({"compatibility": "1.7"}),
icc_based(&rgb, 16, flate, false),
),
(
"RGB, 16 bits, PDF 1.4",
tiff(&DynamicImage::ImageRgb16(colour().to_rgb16()), &rgb),
json!({"compatibility": "1.4"}),
icc_based(&rgb, 8, flate, false),
),
("RGBA", tiff(&clear.clone().into(), &rgb), json!({}), icc_based(&rgb, 8, flate, true)),
(
"RGB, resampled",
tiff(&colour(), &rgb),
json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}),
icc_based(&rgb, 8, flate, false),
),
(
"gray, resampled",
tiff(&colour().grayscale(), &gray),
json!({"compression": {"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}),
icc_based(&gray, 8, flate, false),
),
("RGB, JPEG", tiff(&colour(), &rgb), json!({"compression": {"color": {"compression": "jpeg"}}}), icc_based(&rgb, 8, "DCTDecode", false)),
(
"gray, JPEG",
tiff(&colour().grayscale(), &gray),
json!({"compression": {"gray": {"compression": "jpeg"}}}),
icc_based(&gray, 8, "DCTDecode", false),
),
(
"PNG, resampled",
doc(tagged(&colour(), &rgb, ImageFormat::Png), "image/png"),
json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}),
icc_based(&rgb, 8, flate, false),
),
];
for (what, d, settings, want) in cases {
assert_eq!(export(&d, settings), want, "{what}");
}
// A gray image in an RGB profile stays RGB to keep it, resampled too.
let neutral = tiff(&DynamicImage::ImageRgb8(colour().grayscale().to_rgb8()), &rgb);
for settings in [json!({}), json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})] {
assert_eq!(export(&neutral, settings.clone()), icc_based(&rgb, 8, flate, false), "neutral RGB, {settings}");
}
// A profile of another colour space is left out.
let spaces = export(&tiff(&colour(), &gray), json!({}));
assert!(spaces.len() == 1 && spaces[0].space == "DeviceRGB" && spaces[0].icc.is_none(), "RGB in a gray profile: {spaces:?}");
// Converted pixels are in the destination's colours, not the image's profile.
for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] {
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:?}");
}
}
+1
View File
@@ -1783,6 +1783,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.
- 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, and a profile whose color space differs from the samples', are written without it. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`).
- SVG import shows a raster `<image>` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `<image>` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (<https://www.w3.org/TR/css-images-3/#the-image-orientation>). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`).
- SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (<https://www.w3.org/TR/SVG2/conform.html>). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`).
- File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`).