M4.48: JPEG Options — RGB/CMYK/gray, baseline/optimized/progressive, embedded ICC profiles and image maps

document.export {format: "jpg"} takes colorModel (rgb|cmyk|gray), quality 0–100, method
(baseline|optimized|progressive), scans (3–5), embedIcc and imageMap (none|client|server).
JPEGs are written with jpeg-encoder. CMYK files hold ink amounts drawn as the renderer's ink
planes, so CMYK colours keep their inks (black type stays black ink alone). Profiles are made
in code from the CMS (cms::icc_bytes): the RGB spaces from their primaries, the CMYK spaces as
Lab lookup tables sampled from their model, a grey with sRGB's tone curve, and loaded
profiles as loaded. Image maps write <stem>.html or <stem>.map beside the image for objects
with a URL and an Image Map shape. The JPEG Options dialog gains Color Model, Quality 0–10,
Method and Scans, Image Map and Embed ICC Profile; CMYK documents default to CMYK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 11:25:09 +07:00
co-authored by Claude Opus 5.5
parent db9d937e1a
commit ba4ed2ff93
22 changed files with 1129 additions and 72 deletions
+196
View File
@@ -0,0 +1,196 @@
//! 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.
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)?;
Ok(buf)
}
/// `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();
}
let linear: [f32; 256] = std::array::from_fn(|v| lab::srgb_to_linear(v as f32 / 255.0));
// Relative luminance (sRGB primaries), encoded with sRGB's curve: a grey keeps its value.
rgb.map(|[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
})
.collect()
}
/// `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()
}
+28 -27
View File
@@ -1,7 +1,8 @@
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
//! (resolution in the JFIF header) or lossless WebP.
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile) or lossless WebP.
pub mod jpeg;
pub mod png;
use vectorcraft_doc::{Document, Node, NodeKind};
@@ -27,13 +28,15 @@ pub struct RasterExportOptions {
pub anti_alias: AntiAlias,
/// PNG: Adam7 interlacing.
pub interlaced: bool,
/// JPEG quality 1–100.
/// JPEG quality 0–100.
pub quality: u8,
/// JPEG colour model, coding and profile.
pub jpeg: jpeg::JpegOptions,
}
impl Default for RasterExportOptions {
fn default() -> Self {
Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90 }
Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90, jpeg: jpeg::JpegOptions::default() }
}
}
@@ -55,13 +58,20 @@ impl RasterExportOptions {
}
}
/// Encode a rendered image as `format`.
/// Encode a rendered image as `format`. A CMYK JPEG separates the screen colours;
/// [`Renderer::export_region`] draws the inks instead.
pub fn encode(&self, img: &Rendered, format: RasterFormat) -> Result<Vec<u8>, String> {
match format {
RasterFormat::Png => {
png::encode(&img.to_straight(), img.width, img.height, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced })
}
RasterFormat::Jpeg => jpeg(img, self.quality, Some(self.ppi)),
RasterFormat::Jpeg => {
let px = match self.jpeg.color_model {
jpeg::ColorModel::Cmyk => jpeg::separated(img),
model => jpeg::screen_pixels(img, model == jpeg::ColorModel::Gray),
};
jpeg::encode(&px, img.width, img.height, self.quality, Some(self.ppi), &self.jpeg)
}
RasterFormat::WebP => webp(img),
}
}
@@ -69,35 +79,24 @@ impl RasterExportOptions {
impl Renderer {
/// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`.
/// Callers check the size first ([`crate::raster_size`]).
/// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG is drawn as ink amounts
/// ([`Renderer::render_region_inks`]).
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
if format == RasterFormat::Jpeg && opts.jpeg.color_model == jpeg::ColorModel::Cmyk {
let (w, h) = crate::region_pixels(region, opts.scale());
let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format));
return jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg);
}
let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format));
opts.encode(&img, format)
}
}
/// JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the resolution
/// `ppi` (if any) in the JFIF header.
/// Baseline RGB JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the
/// resolution `ppi` (if any) in the JFIF header and no colour profile.
pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option<f64>) -> Result<Vec<u8>, String> {
let rgb: Vec<u8> = img
.to_straight()
.as_chunks::<4>()
.0
.iter()
.flat_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])]
})
.collect();
let mut buf = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
if let Some(ppi) = ppi {
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(ppi.round().clamp(1.0, u16::MAX as f64) as u16));
}
image::ImageEncoder::write_image(enc, &rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
Ok(buf)
let o = jpeg::JpegOptions { embed_icc: false, ..Default::default() };
jpeg::encode(&jpeg::screen_pixels(img, false), img.width, img.height, quality, ppi, &o)
}
/// Lossless WebP.
@@ -131,3 +130,5 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_jpeg;
+156
View File
@@ -0,0 +1,156 @@
use vectorcraft_color::cms::{self, IccProfile, ProfileKind};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::jpeg::{self, ColorModel, JpegOptions, Method};
use super::*;
/// The segments before the image data: (marker, payload).
fn segments(file: &[u8]) -> Vec<(u8, &[u8])> {
let mut out = vec![];
let mut i = 2;
while i + 4 <= file.len() && file[i] == 0xFF {
let marker = file[i + 1];
let len = u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize;
out.push((marker, &file[i + 4..i + 2 + len]));
if marker == 0xDA {
break;
}
i += 2 + len;
}
out
}
/// The frame header (SOF0/SOF2…): (marker, component count).
fn frame(file: &[u8]) -> (u8, u8) {
let (m, p) = segments(file).into_iter().find(|(m, _)| (0xC0..=0xC2).contains(m)).expect("SOF");
(m, p[5])
}
/// Every APP2 ICC chunk joined in order.
fn icc(file: &[u8]) -> Option<Vec<u8>> {
let chunks: Vec<&[u8]> =
segments(file).into_iter().filter(|(m, p)| *m == 0xE2 && p.starts_with(b"ICC_PROFILE\0")).map(|(_, p)| &p[14..]).collect();
(!chunks.is_empty()).then(|| chunks.concat())
}
fn noise(w: u32, h: u32, channels: usize) -> Vec<u8> {
let mut x: u32 = 0x9e37_79b9;
(0..w as usize * h as usize * channels)
.map(|i| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
// Smooth ramps plus a little noise, as photos and gradients are.
((i / channels) as u32 % w * 255 / w) as u8 ^ (x >> 29) as u8
})
.collect()
}
#[test]
fn size_falls_with_quality() {
let px = noise(64, 48, 3);
let size = |q| jpeg::encode(&px, 64, 48, q, None, &JpegOptions::default()).unwrap().len();
assert!(size(10) < size(50) && size(50) < size(95), "{} {} {}", size(10), size(50), size(95));
assert_eq!(size(0), size(1), "quality 0 is the lowest the coder has");
}
#[test]
fn methods_write_baseline_or_progressive_frames() {
let px = noise(40, 30, 3);
let enc = |method, scans| jpeg::encode(&px, 40, 30, 80, None, &JpegOptions { method, scans, ..Default::default() }).unwrap();
let sos = |f: &[u8]| f.windows(2).filter(|w| w == &[0xFF, 0xDA]).count();
let base = enc(Method::Baseline, 3);
assert_eq!((frame(&base).0, sos(&base)), (0xC0, 1));
let opt = enc(Method::Optimized, 3);
assert_eq!(frame(&opt).0, 0xC0, "optimized tables are still baseline");
assert!(opt.len() < base.len(), "optimized tables are smaller");
let (p3, p5) = (enc(Method::Progressive, 3), enc(Method::Progressive, 5));
assert_eq!(frame(&p3).0, 0xC2, "SOF2");
assert!(sos(&p3) > 1 && sos(&p5) > sos(&p3), "more scans: {} {}", sos(&p3), sos(&p5));
for f in [&base, &p3, &p5] {
let back = image::load_from_memory(f).unwrap();
assert_eq!((back.width(), back.height()), (40, 30));
}
}
#[test]
fn colour_models_write_their_components_and_profiles() {
for (model, comps, kind) in
[(ColorModel::Rgb, 3, ProfileKind::Rgb), (ColorModel::Cmyk, 4, ProfileKind::Cmyk), (ColorModel::Gray, 1, ProfileKind::Gray)]
{
let o = JpegOptions { color_model: model, ..Default::default() };
let file = jpeg::encode(&noise(16, 8, model.channels()), 16, 8, 90, Some(150.0), &o).unwrap();
assert_eq!(frame(&file).1, comps, "{model:?}");
let p = IccProfile::from_bytes(None, &icc(&file).expect("APP2 ICC")).unwrap();
assert_eq!(p.kind, kind, "{model:?}");
let app0 = segments(&file).into_iter().find(|(m, _)| *m == 0xE0).unwrap().1;
assert_eq!(&app0[..5], b"JFIF\0");
assert_eq!(app0[7..12], [1, 0, 150, 0, 150], "dots per inch");
let none = jpeg::encode(&noise(16, 8, model.channels()), 16, 8, 90, None, &JpegOptions { embed_icc: false, ..o }).unwrap();
assert!(icc(&none).is_none());
}
// The CMYK file carries the working CMYK space's profile.
let o = JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() };
let file = jpeg::encode(&[0; 4], 1, 1, 90, None, &o).unwrap();
assert_eq!(&icc(&file).unwrap()[..], &cms::icc_bytes(&cms::active_settings().cmyk).unwrap()[..]);
assert!(jpeg::encode(&[0; 3], 70_000, 1, 90, None, &JpegOptions::default()).is_err(), "too wide");
assert!(jpeg::encode(&[0; 2], 1, 1, 90, None, &JpegOptions::default()).is_err(), "short buffer");
}
#[test]
fn model_and_method_ids_round_trip() {
for m in ColorModel::ALL {
assert_eq!(ColorModel::from_id(m.id()), Some(m));
assert_eq!(ColorModel::from_id(m.label()), Some(m));
}
assert_eq!(ColorModel::from_id("grey"), Some(ColorModel::Gray));
for m in Method::ALL {
assert_eq!(Method::from_id(m.id()), Some(m));
}
assert_eq!(Method::from_id("bogus"), None);
}
/// A 20×10 pt document: a black-ink rectangle on the left half, an RGB red one on the right.
fn ink_doc() -> Document {
let mut d = Document::new(20.0, 10.0);
let l = d.layers[0].id;
let black = Paint::solid(Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 });
let red = Paint::solid(Color::Rgb { r: 1.0, g: 0.0, b: 0.0 });
for (rect, paint) in [(Rect::new(0.0, 0.0, 10.0, 10.0), black), (Rect::new(10.0, 0.0, 20.0, 10.0), red)] {
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(rect), Appearance::basic(paint, Paint::None, 0.0))).unwrap();
}
d
}
#[test]
fn cmyk_exports_keep_the_inks_of_cmyk_colours() {
let d = ink_doc();
let o = RasterExportOptions { jpeg: JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() }, ..Default::default() };
let inks = Renderer::new().render_region_inks(&d, d.artboards[0].rect, 1.0, &o.render_options(RasterFormat::Jpeg));
assert_eq!(inks.len(), 20 * 10 * 4);
let at = |x: usize, y: usize| &inks[(y * 20 + x) * 4..][..4];
assert_eq!(at(3, 5), [0, 0, 0, 255], "black ink alone, not a rich black");
let red = at(15, 5);
assert!(red[1] > 200 && red[2] > 200 && red[0] < 40, "RGB red separates to magenta and yellow: {red:?}");
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
assert_eq!(frame(&file).1, 4, "4 components");
let back = image::load_from_memory(&file).unwrap().to_rgb8();
assert!(back.get_pixel(3, 5).0.iter().all(|v| *v < 60), "black reads back dark: {:?}", back.get_pixel(3, 5));
let r = back.get_pixel(15, 5).0;
assert!(r[0] > 180 && r[1] < 90, "red reads back red: {r:?}");
}
#[test]
fn gray_exports_keep_the_lightness() {
let d = ink_doc();
let o = RasterExportOptions { jpeg: JpegOptions { color_model: ColorModel::Gray, ..Default::default() }, ..Default::default() };
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
assert_eq!(frame(&file).1, 1);
let back = image::load_from_memory(&file).unwrap().to_luma8();
let red = back.get_pixel(15, 5)[0];
// sRGB red's relative luminance 0.2126, encoded: about 127.
assert!((115..=140).contains(&red), "{red}");
}