Files
Hamza f106f32af4 A CMYK JPEG is not labelled JFIF
A CMYK JPEG came out with JFIF's APP0 segment at its head and four components
in the frame:

  APP0/JFIF  APP14/Adobe  APP2[ICC_PROFILE 1/4 … 4/4]  SOF0: 4 components

JFIF describes one- and three-component images, greyscale and YCbCr, so an APP0
segment in front of a four-component frame says two different things about the
file's colours: a reader that trusts JFIF decodes nonsense, and one that checks
refuses the file. Adobe's own CMYK JPEGs carry the APP14 marker the encoder
already writes, and no JFIF. `jpeg-encoder` writes JFIF unconditionally, so the
segment is replaced here.

The resolution JFIF carried moves to an EXIF APP1 segment -- `XResolution`,
`YResolution` and `ResolutionUnit` -- which is where `cmd::fileio::ppi` already
looks when a file has no JFIF, so nothing is lost; with no resolution asked for
the segment is simply dropped. RGB and greyscale files keep JFIF, which is what
it is for.

Found while looking at #3084, whose default RGB exports are valid here; this is
the file that is not.
2026-10-20 02:05:01 +07:00

260 lines
9.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! JPEG export: RGB, CMYK or greyscale pixels, baseline, optimized or progressive (3–5 scans), the
//! resolution in the JFIF header and the colour profile in APP2 segments. Profiles come from the
//! CMS ([`cms::icc_bytes`]): sRGB for RGB (the renderer's pixels are sRGB), the working CMYK space
//! for CMYK, [`cms::GRAY`] for greyscale.
use std::collections::HashMap;
use jpeg_encoder::{ColorType, Density, Encoder};
use vectorcraft_color::cms::{self, lab};
use crate::Rendered;
/// The colour model a JPEG is written in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorModel {
#[default]
Rgb,
/// Ink amounts in the working CMYK space.
Cmyk,
/// Grey with the lightness of the colour.
Gray,
}
impl ColorModel {
pub const ALL: [ColorModel; 3] = [ColorModel::Rgb, ColorModel::Cmyk, ColorModel::Gray];
pub fn id(self) -> &'static str {
match self {
ColorModel::Rgb => "rgb",
ColorModel::Cmyk => "cmyk",
ColorModel::Gray => "gray",
}
}
pub fn label(self) -> &'static str {
match self {
ColorModel::Rgb => "RGB",
ColorModel::Cmyk => "CMYK",
ColorModel::Gray => "Grayscale",
}
}
/// By id or label, ignoring case (`grey` and `grayscale` are grey too).
pub fn from_id(s: &str) -> Option<Self> {
let s = s.to_ascii_lowercase();
Self::ALL.into_iter().find(|m| m.id() == s || m.label().eq_ignore_ascii_case(&s)).or_else(|| (s == "grey").then_some(ColorModel::Gray))
}
/// Bytes per pixel of the pixels [`encode`] takes.
pub fn channels(self) -> usize {
match self {
ColorModel::Rgb => 3,
ColorModel::Cmyk => 4,
ColorModel::Gray => 1,
}
}
/// The profile that describes these pixels.
pub(crate) fn profile(self) -> String {
match self {
ColorModel::Rgb => cms::SRGB.into(),
ColorModel::Cmyk => cms::active_settings().cmyk,
ColorModel::Gray => cms::GRAY.into(),
}
}
}
/// How the image data is coded.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Method {
/// Standard Huffman tables: every decoder reads it.
#[default]
Baseline,
/// Huffman tables optimized for the image: smaller files.
Optimized,
/// Several scans, each adding detail: the image builds up while it loads.
Progressive,
}
impl Method {
pub const ALL: [Method; 3] = [Method::Baseline, Method::Optimized, Method::Progressive];
pub fn id(self) -> &'static str {
match self {
Method::Baseline => "baseline",
Method::Optimized => "optimized",
Method::Progressive => "progressive",
}
}
pub fn label(self) -> &'static str {
match self {
Method::Baseline => "Baseline (Standard)",
Method::Optimized => "Baseline Optimized",
Method::Progressive => "Progressive",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s) || m.label().eq_ignore_ascii_case(s))
}
}
/// Progressive scans: 3 to 5.
pub const SCANS: std::ops::RangeInclusive<u8> = 3..=5;
/// The JPEG options of a raster export.
#[derive(Clone, Debug, PartialEq)]
pub struct JpegOptions {
pub color_model: ColorModel,
pub method: Method,
/// Progressive scans ([`SCANS`]).
pub scans: u8,
/// Embed the colour profile.
pub embed_icc: bool,
}
impl Default for JpegOptions {
fn default() -> Self {
Self { color_model: ColorModel::Rgb, method: Method::Baseline, scans: 3, embed_icc: true }
}
}
/// Encode `px` (`o.color_model`'s channels per pixel: RGB, CMYK ink amounts with 0 = no ink, or
/// grey) at `quality` 0–100, with the resolution `ppi` (if any) in the JFIF header.
pub fn encode(px: &[u8], width: u32, height: u32, quality: u8, ppi: Option<f64>, o: &JpegOptions) -> Result<Vec<u8>, String> {
let fail = |e: jpeg_encoder::EncodingError| format!("JPEG encoding failed: {e}");
let (Ok(w), Ok(h)) = (u16::try_from(width), u16::try_from(height)) else {
return Err(format!("{width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution"));
};
if px.len() as u64 != u64::from(width) * u64::from(height) * o.color_model.channels() as u64 {
return Err("JPEG encoding failed: pixel buffer doesn't match the image size".into());
}
let mut buf = Vec::new();
let mut enc = Encoder::new(&mut buf, quality.clamp(1, 100));
if let Some(ppi) = ppi {
let d = ppi.round().clamp(1.0, u16::MAX as f64) as u16;
enc.set_density(Density::Inch { x: d, y: d });
}
match o.method {
Method::Baseline => {}
Method::Optimized => enc.set_optimized_huffman_tables(true),
Method::Progressive => {
enc.set_optimized_huffman_tables(true);
enc.set_progressive_scans(o.scans.clamp(*SCANS.start(), *SCANS.end()));
}
}
if o.embed_icc {
let icc = cms::icc_bytes(&o.color_model.profile()).map_err(|e| format!("JPEG colour profile: {e}"))?;
enc.add_icc_profile(&icc).map_err(fail)?;
}
let ty = match o.color_model {
ColorModel::Rgb => ColorType::Rgb,
ColorModel::Cmyk => ColorType::Cmyk,
ColorModel::Gray => ColorType::Luma,
};
enc.encode(px, w, h, ty).map_err(fail)?;
// JFIF describes one- and three-component images (greyscale and YCbCr), so its APP0 segment
// must not head a four-component CMYK frame: the file then says two different things about
// its colours, and a strict reader rejects it rather than guessing. Adobe's own CMYK JPEGs
// carry the APP14 marker the encoder already writes, and no JFIF (#3084).
if o.color_model == ColorModel::Cmyk {
unlabel_jfif(&mut buf, ppi);
}
Ok(buf)
}
/// Replace the leading APP0/JFIF segment, which the encoder always writes, with an EXIF segment
/// carrying the same resolution (TIFF `XResolution`, `YResolution` and `ResolutionUnit`, which is
/// where a reader looks when there is no JFIF). Without a resolution the segment is simply
/// dropped.
fn unlabel_jfif(buf: &mut Vec<u8>, ppi: Option<f64>) {
// SOI, then APP0 with its two length bytes and the tag it is identified by.
let Some(head) = buf.get(..13) else { return };
if head[..4] != [0xFF, 0xD8, 0xFF, 0xE0] || &head[6..11] != b"JFIF\0" {
return;
}
let len = usize::from(u16::from_be_bytes([head[4], head[5]]));
if 4 + len > buf.len() {
return;
}
let exif = ppi.filter(|p| p.is_finite() && *p >= 1.0).map(|p| exif_resolution(p.round() as u32)).unwrap_or_default();
buf.splice(2..4 + len, exif);
}
/// An APP1 EXIF segment holding `dpi` as IFD0's resolution: big-endian TIFF, three entries, each
/// rational pointing past the directory.
fn exif_resolution(dpi: u32) -> Vec<u8> {
let mut tiff = Vec::new();
tiff.extend_from_slice(b"MM\0*"); // big-endian, TIFF magic
tiff.extend_from_slice(&8u32.to_be_bytes()); // IFD0 right after the header
tiff.extend_from_slice(&3u16.to_be_bytes()); // three entries
// Entry: tag, type (5 = rational, 3 = short), count, then the value or its offset.
let entry = |tag: u16, ty: u16, value: [u8; 4]| {
let mut e = Vec::with_capacity(12);
e.extend_from_slice(&tag.to_be_bytes());
e.extend_from_slice(&ty.to_be_bytes());
e.extend_from_slice(&1u32.to_be_bytes());
e.extend_from_slice(&value);
e
};
// The two rationals sit after the directory: header 8 + count 2 + 3 entries + next-IFD 4.
let after = 8 + 2 + 3 * 12 + 4;
tiff.extend(entry(282, 5, (after as u32).to_be_bytes())); // XResolution
tiff.extend(entry(283, 5, (after as u32 + 8).to_be_bytes())); // YResolution
tiff.extend(entry(296, 3, [0, 2, 0, 0])); // ResolutionUnit: inches
tiff.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
for _ in 0..2 {
tiff.extend_from_slice(&dpi.to_be_bytes());
tiff.extend_from_slice(&1u32.to_be_bytes());
}
let mut seg = vec![0xFF, 0xE1];
let body_len = 6 + tiff.len() + 2;
seg.extend_from_slice(&u16::try_from(body_len).unwrap_or(u16::MAX).to_be_bytes());
seg.extend_from_slice(b"Exif\0\0");
seg.extend_from_slice(&tiff);
seg
}
/// `img` flattened on white as RGB, or as grey with each colour's lightness.
pub(crate) fn screen_pixels(img: &Rendered, gray: bool) -> Vec<u8> {
let px = img.to_straight();
let rgb = px.as_chunks::<4>().0.iter().map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
});
if !gray {
return rgb.flatten().collect();
}
rgb.map(to_gray()).collect()
}
/// A colour's grey: its relative luminance (sRGB primaries) encoded with sRGB's curve, so a grey
/// keeps its value.
pub(crate) fn to_gray() -> impl Fn([u8; 3]) -> u8 {
let linear: [f32; 256] = std::array::from_fn(|v| lab::srgb_to_linear(v as f32 / 255.0));
move |[r, g, b]| {
let y = 0.2126 * linear[r as usize] + 0.7152 * linear[g as usize] + 0.0722 * linear[b as usize];
(lab::linear_to_srgb(y).clamp(0.0, 1.0) * 255.0).round() as u8
}
}
/// `img` flattened on white with each colour separated into the working CMYK space (ink amounts).
pub(crate) fn separated(img: &Rendered) -> Vec<u8> {
let cms = cms::active();
let intent = cms.settings().intent;
let mut seen: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
screen_pixels(img, false)
.as_chunks::<3>()
.0
.iter()
.flat_map(|p| {
*seen
.entry(*p)
.or_insert_with(|| cms.srgb_to_cmyk(p.map(|v| v as f32 / 255.0), intent).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8))
})
.collect()
}