diff --git a/Cargo.lock b/Cargo.lock index ac5f2cb1..4fce31e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1885,6 +1885,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + [[package]] name = "js-sys" version = "0.3.106" @@ -4239,6 +4245,7 @@ version = "0.1.1" dependencies = [ "flate2", "image", + "jpeg-encoder", "log", "proptest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index baf03ed0..db45e12a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ skrifa = "0.47" harfrust = "0.13" linesweeper = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } # ui egui = "0.36" diff --git a/NOTICE b/NOTICE index 2de0aecb..5f53d3f5 100644 --- a/NOTICE +++ b/NOTICE @@ -23,3 +23,6 @@ list (author, source, license) is in ASSETS.md. Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0). + +JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is +based in part on the work of the Independent JPEG Group. diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 53efb67a..f93fd28f 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -1,10 +1,14 @@ -//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files. +//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files, +//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS. use std::sync::{Arc, OnceLock}; -use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions}; +use moxcms::{ + ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText, + RenderingIntent, ToneReprCurve, TransformF32Executor, TransformOptions, Vector3d, +}; -use super::{CmsError, Intent, ProfileKind}; +use super::{CmsError, Intent, ProfileKind, lab}; fn moxcms_intent(i: Intent) -> RenderingIntent { match i { @@ -27,6 +31,8 @@ type Xf = Option>; pub struct IccProfile { pub name: String, pub kind: ProfileKind, + /// The file it was loaded from (empty for profiles made in code). + source: Vec, profile: ColorProfile, srgb: ColorProfile, to_srgb: [OnceLock; 4], @@ -58,12 +64,25 @@ impl IccProfile { other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))), }; let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile")); - Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() }) + Ok(Self { + name, + kind, + source: Vec::new(), + profile, + srgb: ColorProfile::new_srgb(), + to_srgb: Default::default(), + from_srgb: Default::default(), + }) } pub fn from_bytes(name: Option, bytes: &[u8]) -> Result { let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; - Self::from_profile(name, p) + Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? }) + } + + /// The profile as an ICC file: the bytes it was loaded from, else encoded. + pub fn bytes(&self) -> Result, CmsError> { + if self.source.is_empty() { encode(&self.profile) } else { Ok(self.source.clone()) } } fn layout(&self) -> Layout { @@ -122,14 +141,104 @@ impl IccProfile { } } -/// Built-in RGB working spaces from `moxcms`'s standard primaries. -pub fn builtin_rgb(name: &str) -> Option { - let p = match name { +/// The primaries and tone curves of built-in RGB space `name`. +fn rgb_space(name: &str) -> Option { + Some(match name { super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(), super::DISPLAY_P3 => ColorProfile::new_display_p3(), super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(), super::SRGB => ColorProfile::new_srgb(), _ => return None, - }; - IccProfile::from_profile(Some(name.to_string()), p).ok() + }) +} + +/// Built-in RGB working spaces from `moxcms`'s standard primaries. +pub fn builtin_rgb(name: &str) -> Option { + IccProfile::from_profile(Some(name.to_string()), rgb_space(name)?).ok() +} + +/// Built-in RGB space `name` as an ICC file, under its name here. +pub(super) fn builtin_rgb_bytes(name: &str) -> Result, CmsError> { + encode(&labelled(rgb_space(name).ok_or_else(|| CmsError::UnknownProfile(name.into()))?, name)) +} + +/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible. +pub(super) fn encode(p: &ColorProfile) -> Result, CmsError> { + let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; + // Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each). + if let Some(date) = bytes.get_mut(24..36) { + date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat()); + } + Ok(bytes) +} + +fn text_tag(s: &str) -> Option { + Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())])) +} + +/// Profiles made here carry their name and no copyright claim. +fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile { + p.description = text_tag(name); + p.copyright = text_tag("No copyright, use freely"); + p +} + +/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table. +const A2B_GRID: usize = 9; +const B2A_GRID: usize = 17; + +/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a +/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding). +pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile { + let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32; + let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16; + let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3); + for i in 0..A2B_GRID.pow(4) { + let cmyk = [3, 2, 1, 0].map(|d| unit(i / A2B_GRID.pow(d) % A2B_GRID, A2B_GRID)); + let l = to_lab(cmyk); + a2b.extend([l.l / 100.0, (l.a + 128.0) / 255.0, (l.b + 128.0) / 255.0].map(q)); + } + let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4); + for i in 0..B2A_GRID.pow(3) { + let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID)); + b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q)); + } + let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n]; + let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec| { + let mut grid_points = [0u8; 16]; + grid_points[..inputs].fill(grid as u8); + LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: inputs as u8, + num_output_channels: outputs as u8, + grid_points, + clut: Some(LutStore::Store16(clut)), + // A curves sit on the device side, B curves on the PCS side. + a_curves: identity(4), + b_curves: identity(3), + m_curves: vec![], + matrix: Matrix3d::IDENTITY, + bias: Vector3d::default(), + }) + }; + let a2b = table(4, 3, A2B_GRID, a2b); + let b2a = table(3, 4, B2A_GRID, b2a); + let mut p = ColorProfile::default(); + p.profile_class = ProfileClass::OutputDevice; + p.color_space = DataColorSpace::Cmyk; + p.pcs = DataColorSpace::Lab; + p.rendering_intent = RenderingIntent::RelativeColorimetric; + p.white_point = moxcms::WHITE_POINT_D50.to_xyzd(); + p.media_white_point = Some(p.white_point); + p.lut_a_to_b_perceptual = Some(a2b.clone()); + p.lut_a_to_b_colorimetric = Some(a2b); + p.lut_b_to_a_perceptual = Some(b2a.clone()); + p.lut_b_to_a_colorimetric = Some(b2a); + labelled(p, name) +} + +/// The grey space of greyscale exports: sRGB's tone curve, D50 white. +pub(super) fn gray_profile(name: &str) -> ColorProfile { + let mut p = ColorProfile::new_gray_with_gamma(2.2); + p.gray_trc = ColorProfile::new_srgb().red_trc; + labelled(p, name) } diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 202bb9b7..2f4484c1 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -33,6 +33,8 @@ pub const PROPHOTO_RGB: &str = "ProPhoto RGB"; pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)"; /// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers. pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)"; +/// The grey space greyscale exports are written in: sRGB's tone curve (a grey shows as on screen). +pub const GRAY: &str = "Gray (sRGB tone curve)"; /// Built-in RGB and CMYK working spaces, in menu order. const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB]; @@ -252,6 +254,33 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Result, CmsError> { + static CACHE: Mutex)>> = Mutex::new(Vec::new()); + let name = canonical_name(name); + if let Some(p) = user_profile(name) { + return p.bytes().map(Arc::from); + } + if let Some((_, b)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) { + return Ok(b.clone()); + } + let bytes: Arc<[u8]> = if name == GRAY { + icc::encode(&icc::gray_profile(name))? + } else if BUILTIN_CMYK.contains(&name) { + let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..ColorSettings::default() })?; + icc::encode(&icc::cmyk_profile(name, |c| cms.cmyk_to_lab(c), |l| cms.lab_to_cmyk(l, Intent::RelativeColorimetric)))? + } else { + icc::builtin_rgb_bytes(name)? + } + .into(); + CACHE.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), bytes.clone())); + Ok(bytes) +} + /// Load a `.icc`/`.icm` file from disk (native only). #[cfg(not(target_arch = "wasm32"))] pub fn load_icc_file(path: &std::path::Path) -> Result { @@ -671,3 +700,5 @@ pub(crate) fn cmyk_is_device() -> bool { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_icc; diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs new file mode 100644 index 00000000..5b23c018 --- /dev/null +++ b/crates/color/src/cms/tests_icc.rs @@ -0,0 +1,62 @@ +use super::*; + +fn parse(bytes: &[u8]) -> moxcms::ColorProfile { + moxcms::ColorProfile::new_from_slice(bytes).unwrap() +} + +#[test] +fn builtin_rgb_spaces_encode_under_their_names() { + for name in BUILTIN_RGB { + let p = IccProfile::from_bytes(None, &icc_bytes(name).unwrap()).unwrap(); + assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Rgb)); + } + // Legacy names find today's profile. + assert_eq!(icc_bytes(LEGACY_NAMES[0].0).unwrap(), icc_bytes(WIDE_GAMUT_RGB).unwrap()); + assert!(matches!(icc_bytes("No Such Profile"), Err(CmsError::UnknownProfile(_)))); +} + +#[test] +fn cmyk_profiles_convert_like_the_cms() { + for name in BUILTIN_CMYK { + let bytes = icc_bytes(name).unwrap(); + assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk); + let p = IccProfile::from_bytes(None, &bytes).unwrap(); + assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk)); + let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap(); + for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] { + let want = cms.cmyk_to_srgb(cmyk, false); + let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap(); + let de = delta_e2000(lab::srgb_to_lab(want), lab::srgb_to_lab(got)); + assert!(de < 2.5, "{name} {cmyk:?}: {want:?} vs {got:?} (ΔE {de})"); + } + // And back: an in-gamut colour separates to about the same inks. + let srgb = cms.cmyk_to_srgb([0.1, 0.6, 0.8, 0.0], false); + let back = p.from_srgb(srgb, Intent::RelativeColorimetric).unwrap(); + let again = cms.cmyk_to_srgb([back[0], back[1], back[2], back[3]], false); + assert!(delta_e2000(lab::srgb_to_lab(srgb), lab::srgb_to_lab(again)) < 3.0, "{back:?}"); + } +} + +#[test] +fn gray_is_srgb_toned() { + let p = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap(); + assert_eq!((p.name.as_str(), p.kind), (GRAY, ProfileKind::Gray)); + for v in [0.0f32, 0.25, 0.5, 1.0] { + let got = p.to_srgb(&[v], Intent::RelativeColorimetric).unwrap(); + assert!(got.iter().all(|c| (c - v).abs() < 0.01), "{v} → {got:?}"); + } +} + +#[test] +fn encoded_profiles_are_reproducible() { + assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap()); +} + +#[test] +fn a_loaded_profile_is_embedded_as_loaded() { + let mut bytes = icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap(); + // A byte the parser ignores (in the reserved header area) marks the file as this one. + bytes[100] = 7; + register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap(); + assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]); +} diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 74b32375..9ddf104e 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -1,16 +1,20 @@ //! One encoder per writable format. Each format's options are parsed, typed, from the export params //! (unknown keys are ignored, so one params object can carry the options of several formats). +use std::borrow::Cow; + use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; use vectorcraft_doc::{Artboard, Document}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::jpeg::{self, JpegOptions}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::super::*; use super::Format; +use super::imagemap::{self, MapKind}; const C: &str = "document.export"; @@ -65,6 +69,8 @@ pub struct Encoded { pub files: Vec<(Option, Vec)>, /// Images to write next to the file(s) (SVG `images: "link"`). pub linked: Vec, + /// Image maps, each written beside its image under the image's name (raster `imageMap`). + pub maps: Vec, /// The encoder's warnings (features approximated or left out, options not applied yet). pub warnings: Vec, } @@ -76,13 +82,13 @@ impl Encoded { /// Every file to write for the destination `path` (a path or a file name): the file itself /// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images - /// beside it under their own names. - pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, &'a [u8])> { + /// beside it under their own names, then the image maps as `{image stem}.html` or `.map`. + pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> { // Siblings keep the path's own separators (this runs on every platform and on the web). let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)]; let sibling = |name: &str| format!("{dir}{name}"); - let mut out: Vec<(String, &[u8])> = match self.files.as_slice() { - [(_, bytes)] => vec![(path.to_string(), bytes.as_slice())], + let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() { + [(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))], files => { let boards: Vec = files.iter().map(|(b, _)| b.unwrap_or(0)).collect(); let file = std::path::Path::new(&path[dir.len()..]); @@ -91,18 +97,28 @@ impl Encoded { super::artboard_file_names(doc, &boards) .into_iter() .zip(files) - .map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), bytes.as_slice())) + .map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), Cow::Borrowed(bytes.as_slice()))) .collect() } }; - out.extend(self.linked.iter().map(|l| (sibling(&l.name), l.bytes.as_slice()))); + let maps: Vec<(String, Cow<[u8]>)> = self + .maps + .iter() + .filter_map(|m| { + let image = std::path::Path::new(&out.get(m.file)?.0); + let name = image.file_name()?.to_string_lossy(); + Some((image.with_extension(m.kind.ext()).to_string_lossy().into_owned(), Cow::Owned(m.text(&name).into_bytes()))) + }) + .collect(); + out.extend(self.linked.iter().map(|l| (sibling(&l.name), Cow::Borrowed(l.bytes.as_slice())))); + out.extend(maps); out } /// The one file and the warnings, when the export wrote nothing else. fn single(self, f: &Format) -> Result<(Vec, Vec)> { - if !self.linked.is_empty() { - return Err(bad(C, format!("{} with linked images writes several files: use document.export or document.save", f.label))); + if !self.linked.is_empty() || !self.maps.is_empty() { + return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label))); } match <[_; 1]>::try_from(self.files) { Ok([(_, bytes)]) => Ok((bytes, self.warnings)), @@ -123,6 +139,11 @@ struct RasterOptions { background: Option, anti_alias: Option, interlaced: Option, + color_model: Option, + method: Option, + scans: Option, + embed_icc: Option, + image_map: Option, } impl RasterOptions { @@ -141,11 +162,22 @@ impl RasterOptions { }, anti_alias: self.anti_alias.as_deref().map(anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(), interlaced: self.interlaced.unwrap_or(false), - quality: self.quality.unwrap_or(90), + quality: self.quality.unwrap_or(90).min(100), + jpeg: JpegOptions { + color_model: parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?, + method: parse(self.method.as_deref(), jpeg::Method::from_id, "method", "baseline, optimized or progressive")?, + scans: self.scans.unwrap_or(3).clamp(*jpeg::SCANS.start(), *jpeg::SCANS.end()), + embed_icc: self.embed_icc.unwrap_or(true), + }, }) } } +/// An enum option by id (its default when absent). +fn parse(v: Option<&str>, from_id: fn(&str) -> Option, name: &str, ids: &str) -> Result { + v.map(|s| from_id(s).ok_or_else(|| bad(C, format!("{name} `{s}`: {ids}")))).transpose().map(Option::unwrap_or_default) +} + /// A raster background: `transparent` (also `none` or null), `white`, `black` or a colour /// (`"#rrggbb"`, `[r, g, b]` 0–1, `{c, m, y, k}`, `{gray}`) → opaque RGB, `None` = transparent. pub(crate) fn background(v: &Value) -> std::result::Result, String> { @@ -276,12 +308,18 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()), _ => vec![boards(o.boards.one(n))?], }; + // JPEG Options → Image Map. + let map = match (format, o.image_map.as_deref()) { + (RasterFormat::Jpeg, Some(m)) => MapKind::from_id(m).ok_or_else(|| bad(C, format!("imageMap `{m}`: none, client or server")))?, + _ => MapKind::None, + }; let mut enc = Encoded::default(); let mut renderer = vectorcraft_render::Renderer::new(); for b in chosen { let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; + enc.maps.extend(imagemap::build(doc, region, scale, map, enc.files.len())); let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if format == RasterFormat::Png { diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index e16e9771..9ddd9e88 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -27,7 +27,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { "svg" => json!({ "text": String::from_utf8_lossy(bytes) }), _ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; - let mut out = merge(data(main.first().map_or(&[][..], |m| m.1)), json!({ "warnings": enc.warnings })); + let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings })); if main.len() > 1 { out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect(); } diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs new file mode 100644 index 00000000..ef5e6863 --- /dev/null +++ b/crates/engine/src/cmd/fileio/imagemap.rs @@ -0,0 +1,176 @@ +//! Image maps of raster exports (JPEG Options → Image Map): the objects with a URL (Attributes +//! panel) as clickable areas of the exported image, written beside it as a client-side map (an +//! HTML page with ``/``) or a server-side map (an NCSA `.map` file). + +use std::fmt::Write as _; + +use kurbo::{PathEl, Shape}; +use vectorcraft_doc::{Document, ImageMap, Node, NodeKind}; +use vectorcraft_geom::Rect; + +/// Where the image map goes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MapKind { + #[default] + None, + /// An HTML page showing the image with a `` (`.html`). + Client, + /// An NCSA map file for the web server (`.map`). + Server, +} + +impl MapKind { + pub fn from_id(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "none" => Some(MapKind::None), + "client" | "clientside" | "client-side" => Some(MapKind::Client), + "server" | "serverside" | "server-side" => Some(MapKind::Server), + _ => None, + } + } + + pub fn ext(self) -> &'static str { + match self { + MapKind::Server => "map", + _ => "html", + } + } +} + +/// The shape of a clickable area, in image pixels. +#[derive(Clone, Debug, PartialEq)] +enum Outline { + Rect([i64; 4]), + Poly(Vec<[i64; 2]>), +} + +#[derive(Clone, Debug, PartialEq)] +struct Area { + shape: Outline, + href: String, + alt: String, +} + +/// The image map of one exported image, written next to it. +#[derive(Clone, Debug, PartialEq)] +pub struct Map { + pub kind: MapKind, + /// The image it belongs to (an index into the export's files). + pub file: usize, + width: u32, + height: u32, + /// Topmost first: the first area under the pointer wins. + areas: Vec, +} + +/// The areas of the visible objects of `doc` with a URL and an Image Map shape that `region` +/// (exported at `scale` pixels per point) shows. `None` when there are none. +pub fn build(doc: &Document, region: Rect, scale: f64, kind: MapKind, file: usize) -> Option { + if kind == MapKind::None { + return None; + } + let mut areas = vec![]; + for l in &doc.layers { + collect(l, region, scale, &mut areas); + } + areas.reverse(); + let (width, height) = vectorcraft_render::region_pixels(region, scale); + (!areas.is_empty()).then_some(Map { kind, file, width, height, areas }) +} + +fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec) { + if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { + return; + } + let shape = n.attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); + let px = |x: f64, y: f64| [((x - region.x0) * scale).round() as i64, ((y - region.y0) * scale).round() as i64]; + if let (Some(href), Some(b)) = (n.url(), n.visual_bounds().filter(|b| b.overlaps(region))) + && shape != ImageMap::None + { + let outline = match (&n.kind, shape) { + (NodeKind::Path { path, .. }, ImageMap::Polygon) => { + polygon(&path.to_bezpath(), 0.5 / scale).map(|pts| pts.into_iter().map(|p| px(p.x, p.y)).collect()) + } + _ => None, + }; + let shape = match outline { + Some(pts) => Outline::Poly(pts), + None => { + let ([x0, y0], [x1, y1]) = (px(b.x0, b.y0), px(b.x1, b.y1)); + Outline::Rect([x0, y0, x1, y1]) + } + }; + out.push(Area { shape, href: href.to_string(), alt: n.name.clone().unwrap_or_default() }); + } + for c in n.children().into_iter().flatten() { + collect(c, region, scale, out); + } +} + +/// The first subpath of `path` as straight segments within `tolerance` (at least three points). +fn polygon(path: &kurbo::BezPath, tolerance: f64) -> Option> { + let mut pts = vec![]; + let mut done = false; + kurbo::flatten(path.path_elements(tolerance), tolerance, |el| match el { + _ if done => {} + PathEl::MoveTo(p) if pts.is_empty() => pts.push(p), + PathEl::LineTo(p) => pts.push(p), + PathEl::ClosePath | PathEl::MoveTo(_) => done = true, + _ => {} + }); + pts.dedup(); + if pts.len() > 1 && pts.first() == pts.last() { + pts.pop(); + } + (pts.len() >= 3).then_some(pts) +} + +/// Escape text for an HTML attribute. +fn attr(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).fold(String::with_capacity(s.len()), |mut o, c| { + match c { + '&' => o.push_str("&"), + '"' => o.push_str("""), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + c => o.push(c), + } + o + }) +} + +impl Map { + /// The map file's text for an image named `image` (its file name). + pub fn text(&self, image: &str) -> String { + let join = |pts: &[[i64; 2]], sep: &str| pts.iter().map(|[x, y]| format!("{x},{y}")).collect::>().join(sep); + let mut out = String::new(); + match self.kind { + MapKind::Server => { + for a in &self.areas { + // NCSA format: no spaces inside a URL. + let href = a.href.replace(char::is_whitespace, "%20"); + let _ = match &a.shape { + Outline::Rect([x0, y0, x1, y1]) => writeln!(out, "rect {href} {x0},{y0} {x1},{y1}"), + Outline::Poly(pts) => writeln!(out, "poly {href} {}", join(pts, " ")), + }; + } + } + _ => { + let name = attr(std::path::Path::new(image).file_stem().map_or("image".into(), |s| s.to_string_lossy()).as_ref()); + let _ = writeln!(out, "\n\n\n\n{name}\n\n"); + let _ = + writeln!(out, "\"\"", attr(image), self.width, self.height); + let _ = writeln!(out, ""); + for a in &self.areas { + let (shape, coords) = match &a.shape { + Outline::Rect(r) => ("rect", r.map(|v| v.to_string()).join(",")), + Outline::Poly(pts) => ("poly", join(pts, ",")), + }; + let _ = writeln!(out, "\"{}\"", attr(&a.href), attr(&a.alt)); + } + out.push_str("\n\n\n"); + } + } + out + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 37d05433..2e854264 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -16,6 +16,7 @@ mod batch; mod encode; mod export; +mod imagemap; mod load; pub mod pdf; mod pdfimport; @@ -63,7 +64,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -181,7 +182,8 @@ const RANGE: FormatOption = FormatOption { description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)", }; const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" }; -const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" }; +const QUALITY: FormatOption = + FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" }; const USE_ARTBOARDS: FormatOption = FormatOption { name: "useArtboards", ty: "boolean", @@ -208,6 +210,31 @@ const ANTI_ALIAS: FormatOption = FormatOption { }; const INTERLACED: FormatOption = FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" }; +const COLOR_MODEL: FormatOption = FormatOption { + name: "colorModel", + ty: "string", + default: "\"rgb\"", + description: "rgb, cmyk (ink amounts in the working CMYK space: CMYK colours keep their inks) or gray", +}; +const METHOD: FormatOption = FormatOption { + name: "method", + ty: "string", + default: "\"baseline\"", + description: "baseline (standard), optimized (smaller Huffman tables) or progressive (builds up in scans)", +}; +const SCANS: FormatOption = FormatOption { name: "scans", ty: "integer", default: "3", description: "progressive scans, 3–5" }; +const EMBED_ICC: FormatOption = FormatOption { + name: "embedIcc", + ty: "boolean", + default: "true", + description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve", +}; +const IMAGE_MAP: FormatOption = FormatOption { + name: "imageMap", + ty: "string", + default: "\"none\"", + description: "none, client (an HTML page with , .html) or server (an NCSA .map): the areas of objects with a URL and an Image Map shape (attributes.set)", +}; /// A format `document.open` reads but nothing writes yet. const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format { @@ -258,7 +285,22 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY], + options: &[ + ARTBOARD, + ARTBOARDS, + RANGE, + USE_ARTBOARDS, + PPI, + SCALE, + BACKGROUND, + ANTI_ALIAS, + QUALITY, + COLOR_MODEL, + METHOD, + SCANS, + EMBED_ICC, + IMAGE_MAP, + ], }, reader("gif", "GIF", &["gif"], "image/gif", true), Format { @@ -459,7 +501,7 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document, enc: &Encoded, extra: Value) -> Result { let files = enc.named(doc, path.unwrap_or(name)); let (main, linked) = files.split_at(enc.files.len()); - let row = |(name, bytes): &(String, &[u8])| match path { + let row = |(name, bytes): &(String, std::borrow::Cow<[u8]>)| match path { Some(_) => json!(name), None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; @@ -528,3 +570,6 @@ mod tests_raster; #[cfg(test)] mod tests_exportas; + +#[cfg(test)] +mod tests_jpeg; diff --git a/crates/engine/src/cmd/fileio/tests_jpeg.rs b/crates/engine/src/cmd/fileio/tests_jpeg.rs new file mode 100644 index 00000000..2e3c0d15 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_jpeg.rs @@ -0,0 +1,89 @@ +//! JPEG Options: colour model, coding method, scans, profile and image maps. + +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); + s +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// The JPEG's SOF marker and the number of components in its frame header. +fn frame(file: &[u8]) -> (u8, u8) { + let mut i = 2; + while !(0xC0..=0xC2).contains(&file[i + 1]) { + i += 2 + u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize; + } + (file[i + 1], file[i + 9]) +} + +#[test] +fn jpeg_options_reach_the_file() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let jpg = |s: &mut Session, p: Value| b64(&s.execute("document.export", &merge(json!({"format": "jpg"}), p)).unwrap()); + assert_eq!(frame(&jpg(&mut s, json!({}))), (0xC0, 3), "baseline RGB by default"); + assert_eq!(frame(&jpg(&mut s, json!({"colorModel": "cmyk"}))), (0xC0, 4)); + assert_eq!(frame(&jpg(&mut s, json!({"colorModel": "grayscale"}))), (0xC0, 1)); + assert_eq!(frame(&jpg(&mut s, json!({"method": "progressive", "scans": 5}))).0, 0xC2); + let icc = |f: &[u8]| f.windows(12).any(|w| w == b"ICC_PROFILE\0"); + assert!(icc(&jpg(&mut s, json!({}))) && !icc(&jpg(&mut s, json!({"embedIcc": false})))); + assert!(jpg(&mut s, json!({"quality": 0})).len() < jpg(&mut s, json!({"quality": 100})).len()); + for bad in [json!({"colorModel": "lab"}), json!({"method": "fast"}), json!({"imageMap": "both"})] { + assert!(s.execute("document.export", &merge(json!({"format": "jpg"}), bad.clone())).is_err(), "{bad}"); + } + let r = s.execute("document.formats", &json!({})).unwrap(); + let jpg = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "jpg").unwrap(); + for k in ["colorModel", "method", "scans", "embedIcc", "imageMap"] { + assert!(jpg["options"].get(k).is_some(), "{k}"); + } +} + +#[test] +fn image_maps_list_the_objects_with_a_url() { + let mut s = session(); + // Areas cover the visual bounds: no stroke, so they are the shapes' own. + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 20, "height": 10})).unwrap(); + s.execute("attributes.set", &json!({"url": "https://example.com/a?b=1&c=2", "imageMap": "rectangle"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 35, "y": 20, "width": 20, "height": 10})).unwrap(); + s.execute("attributes.set", &json!({"url": "https://example.com/round", "imageMap": "polygon"})).unwrap(); + // A URL without a map shape makes no area. + s.execute("shape.rectangle", &json!({"x": 0, "y": 30, "width": 5, "height": 5})).unwrap(); + s.execute("attributes.set", &json!({"url": "https://example.com/none"})).unwrap(); + + let r = s.execute("document.export", &json!({"format": "jpg", "imageMap": "client", "ppi": 144})).unwrap(); + let linked = r["linked"].as_array().unwrap(); + assert_eq!(linked.len(), 1); + assert_eq!(linked[0]["name"], "Untitled-1.html"); + let html = String::from_utf8(b64(&linked[0])).unwrap(); + assert!(html.contains(" &'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 { + 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::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 = 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, o: &JpegOptions) -> Result, 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 { + 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 { + 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() +} diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index 2a3eef2a..0d90ff81 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -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, 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, 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) -> Result, String> { - let rgb: Vec = 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 { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_jpeg; diff --git a/crates/render/src/encode/tests_jpeg.rs b/crates/render/src/encode/tests_jpeg.rs new file mode 100644 index 00000000..32366ed5 --- /dev/null +++ b/crates/render/src/encode/tests_jpeg.rs @@ -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> { + 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 { + 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}"); +} diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 34136df9..8ca101d2 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -225,6 +225,18 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec< out } +/// Each pixel's ink amounts (C, M, Y, K bytes, 0 = no ink) from its ink planes (premultiplied +/// RGBA8 drawn with [`Ink::Cmy`] and [`Ink::K`]): a plane holds the complement of its inks, so +/// the ink is coverage minus plane value (partly covered pixels carry that much ink). +pub(crate) fn amounts(cmy: &[u8], k: &[u8]) -> Vec { + cmy.as_chunks::<4>() + .0 + .iter() + .zip(k.as_chunks::<4>().0) + .flat_map(|(p, q)| [p[3].saturating_sub(p[0]), p[3].saturating_sub(p[1]), p[3].saturating_sub(p[2]), q[3].saturating_sub(q[0])]) + .collect() +} + impl Renderer { /// Placed image `key` (decoded as `pm`) as painted with `ink`, cached. pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc, ink: Ink) -> Arc { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b6e9f6cb..7d281d9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -397,6 +397,12 @@ impl Renderer { /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + self.render_as(doc, width, height, view, opts, false) + } + + /// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of + /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). + fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { let start = now(); let prepared = proof::prepare(doc, opts); let doc: &Document = &prepared; @@ -417,13 +423,13 @@ impl Renderer { self.stats.micros = now().saturating_sub(start); return Rendered { width: w as u32, height: h as u32, pixels }; } - let mut pixels = if ink::blends_in_cmyk(doc, opts) { + let mut pixels = if inks || ink::blends_in_cmyk(doc, opts) { // Blending in CMYK: one frame per ink plane, then their inks shown on screen. let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h); let stats = self.stats; let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h); self.stats = stats; - ink::compose(&cmy, &k, opts.background) + if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts.background) } } else { self.draw_frame(&frame, w, h) }; @@ -441,7 +447,9 @@ impl Renderer { if self.shadows.len() > 256 { self.shadows.retain(|_, e| g - e.stamp <= 3); } - proof::post(&mut pixels, opts); + if !inks { + proof::post(&mut pixels, opts); + } self.stats.micros = now().saturating_sub(start); Rendered { width: w as u32, height: h as u32, pixels } } @@ -536,6 +544,16 @@ impl Renderer { self.render(doc, w, h, view, opts) } + /// `region` of `doc` as ink amounts in the working CMYK space, 4 bytes a pixel (0 = no ink): + /// drawn as the two ink planes of [`ink`], so CMYK colours keep their own inks (black type + /// stays black ink alone) and RGB ones are separated with the colour settings. Where nothing + /// is drawn (no background) there is no ink. CMYK exports draw this. + pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { + let (w, h) = region_pixels(region, scale); + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + self.render_as(doc, w, h, view, opts, true).pixels + } + /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option { self.render_node_thumbnail(doc, doc.node(id)?, size, None) diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index bcb16b88..b6f73006 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -308,3 +308,5 @@ mod tests; mod tests_export; #[cfg(test)] mod tests_import_pdf; +#[cfg(test)] +mod tests_raster_formats; diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 51feb18a..7e60f65a 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,11 +1,13 @@ //! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), -//! JPEG Options (quality instead of interlacing; no transparency) and WebP Options. Fields are -//! `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the dialog's. +//! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image +//! map) and WebP Options. Fields are `document.export` params (format, path, artboard choice…); +//! `__`-prefixed ones are the dialog's. use serde::Deserialize; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::jpeg::{self, ColorModel, Method}; use super::{DialogSpec, form}; use crate::state::Dialog; @@ -22,6 +24,11 @@ const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "Hi const BACKGROUNDS: [&str; 3] = ["transparent", "white", "black"]; /// Their labels, then `Other` (a colour). const BACKGROUND_LABELS: [&str; 4] = ["Transparent", "White", "Black", "Other"]; +/// JPEG image map param values and their labels. +const IMAGE_MAPS: [&str; 3] = ["none", "client", "server"]; +const IMAGE_MAP_LABELS: [&str; 3] = ["None", "Client-side (.html)", "Server-side (.map)"]; +/// JPEG quality on the dialog's 0–10 scale: the band each step falls in. +const QUALITY_BANDS: [&str; 11] = ["Low", "Low", "Low", "Medium", "Medium", "Medium", "High", "High", "Maximum", "Maximum", "Maximum"]; /// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`). fn kind(f: &Format) -> String { @@ -43,10 +50,25 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { o.insert("background".into(), json!(if white { "white" } else { "transparent" })); o.insert("antiAlias".into(), json!(AntiAlias::default().id())); match f.id { - "png" => o.insert("interlaced".into(), json!(false)), - "jpg" => o.insert("quality".into(), json!(90)), - _ => None, - }; + "png" => { + o.insert("interlaced".into(), json!(false)); + } + "jpg" => { + // A CMYK document exports CMYK by default. + let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk); + let model = if cmyk { ColorModel::Cmyk } else { ColorModel::Rgb }; + let defaults = jpeg::JpegOptions::default(); + o.extend([ + ("quality".into(), json!(90)), + ("colorModel".into(), json!(model.id())), + ("method".into(), json!(defaults.method.id())), + ("scans".into(), json!(defaults.scans)), + ("embedIcc".into(), json!(defaults.embed_icc)), + ("imageMap".into(), json!(IMAGE_MAPS[0])), + ]); + } + _ => {} + } if let Some((w, h)) = size { o.insert("__size".into(), json!([w, h])); } @@ -131,14 +153,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "interlaced", "Interlaced"); ui.end_row(); } - "jpg" => { - label(ui, "Quality:"); - let mut q = d.f64("quality", 90.0).round() as u8; - if ui.add(egui::Slider::new(&mut q, 1..=100)).changed() { - d.fields.insert("quality".into(), json!(q)); - } - ui.end_row(); - } + "jpg" => jpeg_rows(ui, d, &label), _ => {} } @@ -154,6 +169,56 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The JPEG Options rows: colour model, quality (0–10, stored ×10), method and scans, profile and +/// image map. +fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) { + label(ui, "Color Model:"); + choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label)); + ui.end_row(); + + label(ui, "Quality:"); + let mut q = (d.f64("quality", 90.0) / 10.0).round().clamp(0.0, 10.0) as u8; + ui.horizontal(|ui| { + if ui.add(egui::Slider::new(&mut q, 0..=10)).changed() { + d.fields.insert("quality".into(), json!(u32::from(q) * 10)); + } + label(ui, QUALITY_BANDS[q as usize]); + }); + ui.end_row(); + + label(ui, "Method:"); + ui.horizontal(|ui| { + choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label)); + if Method::from_id(&d.str("method")) == Some(Method::Progressive) { + label(ui, "Scans:"); + let mut scans = d.f64("scans", 3.0).round().clamp(*jpeg::SCANS.start() as f64, *jpeg::SCANS.end() as f64) as u8; + if ui.add(egui::DragValue::new(&mut scans).range(jpeg::SCANS)).changed() { + d.fields.insert("scans".into(), json!(scans)); + } + } + }); + ui.end_row(); + + label(ui, "Image Map:"); + choice(ui, d, "imageMap", &IMAGE_MAPS, &IMAGE_MAP_LABELS); + ui.end_row(); + + ui.label(""); + form::check(ui, d, "embedIcc", "Embed ICC Profile"); + ui.end_row(); +} + +/// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown). +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) { + let cur = d.str(key); + let at = ids.iter().position(|v| v.eq_ignore_ascii_case(&cur)).unwrap_or(0); + if let Some(i) = widgets::dropdown(ui, ("ro", key), labels.get(at).copied().unwrap_or_default(), labels, 150.0) + && let Some(v) = ids.get(i) + { + d.fields.insert(key.into(), json!(v)); + } +} + /// Write the file(s) with the chosen options. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (format, path) = (d.str("format"), d.str("path")); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 42e84016..8d5b5429 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -10,11 +10,11 @@ use vectorcraft_engine::cmd::fileio; use super::*; use crate::Services; -type Written = Rc)>>>; +pub(super) type Written = Rc)>>>; /// An app with `artboards` 60×40 pt artboards whose save dialog answers `/out/` and /// whose writer records what it is given. -fn app(artboards: usize) -> (VectorcraftApp, Written) { +pub(super) fn app(artboards: usize) -> (VectorcraftApp, Written) { let written = Written::default(); let w = written.clone(); let services = Services { @@ -33,18 +33,18 @@ fn app(artboards: usize) -> (VectorcraftApp, Written) { } /// One headless frame of the dialog layer. -fn frame(app: &mut VectorcraftApp) { +pub(super) fn frame(app: &mut VectorcraftApp) { let ctx = egui::Context::default(); theme::install_fonts(&ctx); let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx())); out.textures_delta.clear(); } -fn kind(app: &VectorcraftApp) -> Option<&str> { +pub(super) fn kind(app: &VectorcraftApp) -> Option<&str> { app.ui.dialog.as_ref().map(|d| d.kind.as_str()) } -fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Value) { +pub(super) fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Value) { app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value); } diff --git a/crates/ui-egui/src/dialogs/tests_raster_formats.rs b/crates/ui-egui/src/dialogs/tests_raster_formats.rs new file mode 100644 index 00000000..389105de --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -0,0 +1,44 @@ +//! The options of the raster formats beyond PNG, drawn headlessly: JPEG. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, set}; +use super::*; + +/// A JPEG's SOF marker and component count. +fn jpeg_frame(file: &[u8]) -> (u8, u8) { + let mut i = 2; + while !(0xC0..=0xC2).contains(&file[i + 1]) { + i += 2 + u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize; + } + (file[i + 1], file[i + 9]) +} + +#[test] +fn jpeg_options_pick_the_colour_model_method_and_profile() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "jpg"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("jpgOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("colorModel"), d.str("method"), d.f64("scans", 0.0), d.bool("embedIcc"), d.str("imageMap")), + ("rgb".into(), "baseline".into(), 3.0, true, "none".into()) + ); + frame(&mut app); + set(&mut app, "method", json!("progressive")); + set(&mut app, "scans", json!(4)); + set(&mut app, "colorModel", json!("cmyk")); + set(&mut app, "quality", json!(30)); + frame(&mut app); + confirm(&mut app).unwrap(); + let file = written.borrow()[0].1.clone(); + assert_eq!(jpeg_frame(&file), (0xC2, 4), "progressive CMYK"); + assert!(file.windows(12).any(|w| w == b"ICC_PROFILE\0")); + + // A CMYK document exports CMYK by default. + app.run("object.convertDocumentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("file.exportAs", json!({"format": "jpg"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ec669659..5417527d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,6 +26,7 @@ | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | +| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |