diff --git a/Cargo.lock b/Cargo.lock index ac5f2cb1f..cf4ab07ee 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" @@ -4238,7 +4244,9 @@ name = "vectorcraft-render" version = "0.1.1" dependencies = [ "flate2", + "gif", "image", + "jpeg-encoder", "log", "proptest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index baf03ed07..9f086091a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ 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"] } +gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # ui egui = "0.36" diff --git a/NOTICE b/NOTICE index 2de0aecbd..5f53d3f57 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 53efb67a0..f93fd28f8 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, + }) +} + +/// 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(), + }) }; - IccProfile::from_profile(Some(name.to_string()), p).ok() + 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 202bb9b75..2f4484c19 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 000000000..5b23c0182 --- /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 74b323754..4848b37d6 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -1,16 +1,21 @@ //! 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::quantize::{Dither, PaletteOptions, Reduction}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::super::*; use super::Format; +use super::imagemap::{self, MapKind}; const C: &str = "document.export"; @@ -65,6 +70,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 +83,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 +98,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 +140,18 @@ struct RasterOptions { background: Option, anti_alias: Option, interlaced: Option, + color_model: Option, + method: Option, + scans: Option, + embed_icc: Option, + image_map: Option, + colors: Option, + reduction: Option, + dither: Option, + dither_amount: Option, + transparency: Option, + matte: Option, + lossless: Option, } impl RasterOptions { @@ -141,11 +170,38 @@ 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), + }, + palette: PaletteOptions { + colors: self.colors.unwrap_or(256).clamp(2, 256), + reduction: parse( + self.reduction.as_deref(), + Reduction::from_id, + "reduction", + "perceptual, selective, adaptive, web, blackWhite or gray", + )?, + dither: parse(self.dither.as_deref(), Dither::from_id, "dither", "none, diffusion, pattern or noise")?, + dither_amount: self.dither_amount.unwrap_or(100).min(100), + transparency: self.transparency.unwrap_or(true), + matte: match &self.matte { + Some(v) => background(v).map_err(|e| bad(C, format!("matte: {e}")))?, + None => Some([255; 3]), + }, + }, }) } } +/// 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> { @@ -254,12 +310,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { d.template = true; vectorcraft_format::save_file(&d) } + "txt" => super::text::encode(doc, p)?, "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); } - "png" | "jpg" | "webp" => { + "png" | "jpg" | "webp" | "gif" | "png8" => { let o: RasterOptions = options(f, p)?; // New Document → Background Contents: White makes the export opaque, unless `background` // says otherwise (JPEG has no alpha: white either way). @@ -269,6 +326,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let format = match f.id { "png" => RasterFormat::Png, "jpg" => RasterFormat::Jpeg, + "gif" => RasterFormat::Gif, + "png8" => RasterFormat::Png8, _ => RasterFormat::WebP, }; // Use Artboards: one file per chosen artboard (default all); else the one chosen. @@ -276,15 +335,21 @@ 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 { + let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) { vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title)) } else { bytes @@ -294,6 +359,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); } + if format == RasterFormat::WebP && o.lossless == Some(false) { + enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); + } return Ok(enc); } _ => return Err(bad(C, format!("no encoder for {} yet", f.label))), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index e16e97711..7f8c91bc0 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -1,11 +1,12 @@ //! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`. +use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use super::super::*; use super::encode::single_artboard; @@ -18,7 +19,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; let expanded = super::pdf::expand_preset(s, "document.serialize", p)?; let p = &*expanded; - let doc = &s.doc()?.doc; + let doc = &*source(s, f, p, "document.serialize")?; let enc = encode_all(doc, f.id, p)?; let files = enc.named(doc, &default_name(doc, f.extensions[0])); let (main, linked) = files.split_at(enc.files.len()); @@ -27,7 +28,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(); } @@ -42,7 +43,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { let f = writable("document.export", str_param(p, "format"), path)?; let expanded = super::pdf::expand_preset(s, "document.export", p)?; let p = &*expanded; - let doc = &s.doc()?.doc; + let doc = &*source(s, f, p, "document.export")?; let enc = encode_all(doc, f.id, p)?; write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) } @@ -61,6 +62,34 @@ pub(super) fn without_artboards(p: &Value) -> Value { q } +/// The document an export of `f` writes: the active one, or for text with `selectionOnly` the +/// selected objects alone. +fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result> { + if f.id == "txt" && bool_or(p, "selectionOnly", false) { + return Ok(Cow::Owned(selection(s, cmd)?.0)); + } + Ok(Cow::Borrowed(&s.doc()?.doc)) +} + +/// The selected objects (not on template layers) alone on one layer, with one artboard: their +/// visual bounds. +fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> { + let ids = edit::selected_roots(s)?; + let st = s.doc()?; + let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); + // On a template layer or sublayer, at any depth. + let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); + let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(cmd, "select something to export"))?; + let mut d = single_artboard(&st.doc, bounds, "Selection"); + let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = nodes; + } + d.layers = vec![Arc::new(layer)]; + Ok((d, bounds)) +} + /// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on /// template layers are guides, not artwork, and are left out. pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { @@ -69,19 +98,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { let f = writable(C, str_param(p, "format"), path)?; let expanded = super::pdf::expand_preset(s, C, p)?; let p = &*expanded; - let ids = edit::selected_roots(s)?; - let st = s.doc()?; - let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); - // On a template layer or sublayer, at any depth. - let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); - let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?; - let mut d = single_artboard(&st.doc, bounds, "Selection"); - let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); - if let Some(ch) = layer.children_mut() { - *ch = nodes; - } - d.layers = vec![Arc::new(layer)]; + let (d, bounds) = selection(s, C)?; let bytes = encode(&d, f.id, &without_artboards(p))?; write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] })) } @@ -101,7 +118,7 @@ fn screen_format(row: &Value) -> Result { } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; if matches!(format.id, "vectorcraft" | "template") { - return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf")); + return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf")); } // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one // left over from a raster row switched to SVG or PDF). @@ -179,3 +196,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { } Ok(json!({ "files": files })) } + +/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless +/// `transparent`. +pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.exportForOffice"; + let path = str_param(p, "path"); + let ppi = f64_or(p, "ppi", 150.0); + let doc = &s.doc()?.doc; + let artboard = match p.get("artboard") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "artboard is a 0-based index"))?, + }; + if artboard >= doc.artboards.len() { + return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len()))); + } + let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" }; + let bytes = encode(doc, "png", &json!({ "ppi": ppi, "background": background, "artboard": artboard }))?; + // The pixel size, from the PNG header. + let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes); + write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) })) +} diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs new file mode 100644 index 000000000..ef5e68633 --- /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 37d054338..1ec7fcbf8 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; @@ -23,6 +24,7 @@ pub mod pngtext; pub mod ppi; mod save; mod svg; +mod text; use serde_json::{Value, json}; @@ -54,7 +56,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -63,7 +65,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|gif|png8|txt|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .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), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; 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 ), @@ -81,7 +83,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", has_doc, export::export_for_screens ), @@ -104,6 +106,15 @@ pub fn specs() -> Vec { always, pdfimport::pdf_info ), + cmd!( + "document.exportForOffice", + "Save for Office Documents…", + ["File"], + None, + "{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}", + has_doc, + export::export_for_office + ), ] .into_iter() .chain(save::specs()) @@ -181,7 +192,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 +220,85 @@ 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)", +}; + +const COLORS: FormatOption = + FormatOption { name: "colors", ty: "integer", default: "256", description: "most palette entries, 2–256 (the transparent one included)" }; +const REDUCTION: FormatOption = FormatOption { + name: "reduction", + ty: "string", + default: "\"selective\"", + description: "the palette: perceptual, selective (also keeps rarer colours, snaps near web colours), adaptive (most used), web (web-safe), blackWhite or gray; art with that many colours or fewer keeps them exactly", +}; +const DITHER: FormatOption = FormatOption { + name: "dither", + ty: "string", + default: "\"diffusion\"", + description: "none, diffusion (Floyd–Steinberg), pattern (8×8 ordered) or noise", +}; +const DITHER_AMOUNT: FormatOption = FormatOption { name: "ditherAmount", ty: "integer", default: "100", description: "dither strength 0–100" }; +const TRANSPARENCY: FormatOption = FormatOption { + name: "transparency", + ty: "boolean", + default: "true", + description: "pixels under half opacity become one transparent entry (false: everything is blended over the matte)", +}; +const MATTE: FormatOption = FormatOption { + name: "matte", + ty: "string", + default: "\"white\"", + description: "the colour partly transparent edges are blended over (\"#rrggbb\", white, black), or none (they keep their colour)", +}; +const LOSSLESS: FormatOption = FormatOption { + name: "lossless", + ty: "boolean", + default: "true", + description: "lossless WebP; false asks for lossy, which isn't available yet (written lossless, with a warning)", +}; +const WEBP_QUALITY: FormatOption = + FormatOption { name: "quality", ty: "integer", default: "90", description: "lossy quality 0–100 (for when lossy WebP is available)" }; +/// The options of the palette formats (PNG-8, GIF). +const PALETTE_OPTIONS: &[FormatOption] = &[ + ARTBOARD, + ARTBOARDS, + RANGE, + USE_ARTBOARDS, + PPI, + SCALE, + BACKGROUND, + ANTI_ALIAS, + INTERLACED, + COLORS, + REDUCTION, + DITHER, + DITHER_AMOUNT, + TRANSPARENCY, + MATTE, +]; /// 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,9 +349,24 @@ 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 { id: "gif", label: "GIF", extensions: &["gif"], mime: "image/gif", read: true, write: true, raster: true, options: PALETTE_OPTIONS }, Format { id: "webp", label: "WebP", @@ -269,7 +375,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], }, reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), reader("bmp", "BMP", &["bmp"], "image/bmp", true), @@ -283,6 +389,8 @@ pub const FORMATS: &[Format] = &[ raster: false, options: &[], }, + Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS }, + Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -459,7 +567,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 +636,12 @@ mod tests_raster; #[cfg(test)] mod tests_exportas; + +#[cfg(test)] +mod tests_jpeg; + +#[cfg(test)] +mod tests_palette; + +#[cfg(test)] +mod tests_text; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 32cafb001..53f9cf15b 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); - assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]); + assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); assert_eq!(png["options"]["scale"]["default"], 1); 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 000000000..2e3c0d156 --- /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(" Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// A 40×30 pt document with a red rectangle and a blue ellipse, no strokes. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 30})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 22, "y": 5, "width": 15, "height": 20})).unwrap(); + // Fills the new ellipse (selected). + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s +} + +/// PNG `PLTE` entries. +fn plte_len(file: &[u8]) -> usize { + let i = file.windows(4).position(|w| w == b"PLTE").expect("PLTE"); + u32::from_be_bytes(file[i - 4..i].try_into().unwrap()) as usize / 3 +} + +#[test] +fn png8_and_gif_export_palettes() { + let mut s = session(); + let png8 = b64(&s.execute("document.export", &json!({"format": "png8", "colors": 16})).unwrap()); + assert_eq!(png8[25], 3, "colour type 3"); + assert!(plte_len(&png8) <= 16); + let img = image::load_from_memory(&png8).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (40, 30)); + assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]); + assert_eq!(img.get_pixel(39, 0)[3], 0, "transparent where there is no art"); + + let gif = b64(&s.execute("document.export", &json!({"format": "gif", "background": "white", "dither": "none"})).unwrap()); + assert_eq!(&gif[..6], b"GIF89a"); + let img = image::load_from_memory_with_format(&gif, image::ImageFormat::Gif).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]); + assert_eq!(img.get_pixel(39, 0).0, [255, 255, 255, 255], "the white background"); + + // Black & White: two entries whatever the art. + let bw = b64(&s.execute("document.export", &json!({"format": "png8", "reduction": "blackWhite", "transparency": false})).unwrap()); + assert!(plte_len(&bw) <= 2); + for bad in [json!({"reduction": "octree"}), json!({"dither": "lots"}), json!({"matte": "plaid"})] { + assert!(s.execute("document.export", &merge(json!({"format": "gif"}), bad.clone())).is_err(), "{bad}"); + } +} + +#[test] +fn palette_formats_reach_export_as_and_screens() { + let mut s = session(); + // A .png path stays PNG; png8 is asked for by format. + let r = s.execute("document.formats", &json!({})).unwrap(); + let f = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone(); + assert_eq!((f("png8")["extensions"][0].as_str(), f("png8")["read"].as_bool()), (Some("png"), Some(false))); + assert_eq!(f("gif")["write"], true); + for k in ["colors", "reduction", "dither", "ditherAmount", "transparency", "matte", "interlaced"] { + assert!(f("gif")["options"].get(k).is_some() && f("png8")["options"].get(k).is_some(), "{k}"); + } + assert_eq!(format_for_name("a.png").unwrap().id, "png"); + let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "gif", "scale": 2}, {"format": "png8"}]})).unwrap(); + let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Artboard-1@2x.gif", "Artboard-1.png"]); + let gif = vectorcraft_format::base64_decode(r["files"][0]["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(image::load_from_memory(&gif).unwrap().width(), 80); +} diff --git a/crates/engine/src/cmd/fileio/tests_text.rs b/crates/engine/src/cmd/fileio/tests_text.rs new file mode 100644 index 000000000..9a41aead5 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_text.rs @@ -0,0 +1,87 @@ +//! Export As Text, WebP options and Save for Office Documents. + +use serde_json::{Value, json}; + +use super::*; + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn text(s: &mut Session, x: f64, y: f64, t: &str) -> u64 { + s.execute("text.create", &json!({"x": x, "y": y, "text": t})).unwrap()["id"].as_u64().unwrap() +} + +#[test] +fn text_export_writes_the_stories_in_stacking_order() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + text(&mut s, 10.0, 200.0, "Back story\nsecond line"); + let front = text(&mut s, 10.0, 40.0, "Front"); + // A thread of two frames is one story, written once. + let a = + s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Threaded words", "area": {"width": 120, "height": 40}})).unwrap()["id"].clone(); + let b = s.execute("text.create", &json!({"x": 200, "y": 80, "text": "", "area": {"width": 120, "height": 40}})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + // Hidden objects are left out. + let hidden = text(&mut s, 10.0, 260.0, "Hidden"); + s.execute("select.set", &json!({"ids": [hidden]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + + let export = |s: &mut Session, p: Value| b64(&s.execute("document.export", &merge(json!({"format": "txt"}), p)).unwrap()); + let utf8 = String::from_utf8(export(&mut s, json!({}))).unwrap(); + assert_eq!(utf8, "Back story\nsecond line\nFront\nThreaded words\n", "back to front, one line ending after each line"); + let crlf = export(&mut s, json!({"lineEndings": "crlf"})); + assert_eq!(String::from_utf8(crlf).unwrap(), utf8.replace('\n', "\r\n")); + let utf16 = export(&mut s, json!({"encoding": "utf16"})); + assert_eq!(&utf16[..2], [0xFF, 0xFE], "byte order mark"); + let units: Vec = utf16[2..].chunks(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + assert_eq!(String::from_utf16(&units).unwrap(), utf8); + // Only the selection. + s.execute("select.set", &json!({"ids": [front]})).unwrap(); + assert_eq!(export(&mut s, json!({"selectionOnly": true})), b"Front\n"); + for bad in [json!({"encoding": "latin1"}), json!({"lineEndings": "cr"})] { + assert!(s.execute("document.export", &merge(json!({"format": "txt"}), bad.clone())).is_err(), "{bad}"); + } + // A .txt path picks the format. + let dir = std::env::temp_dir().join(format!("vc-text-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("story.txt"); + assert_eq!(s.execute("document.export", &json!({"path": path.to_string_lossy()})).unwrap()["format"], "txt"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), utf8); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn webp_is_lossless_and_says_so_when_lossy_is_asked_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 30, "height": 20})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + let r = s.execute("document.export", &json!({"format": "webp"})).unwrap(); + let file = b64(&r); + assert_eq!((&file[..4], &file[8..12], &file[12..16]), (&b"RIFF"[..], &b"WEBP"[..], &b"VP8L"[..]), "lossless bitstream"); + assert_eq!(r["warnings"], json!([])); + let r = s.execute("document.export", &json!({"format": "webp", "lossless": false, "quality": 60, "background": "black"})).unwrap(); + assert_eq!(&b64(&r)[12..16], b"VP8L"); + assert!(r["warnings"][0].as_str().unwrap().contains("lossless"), "{r}"); + assert_eq!(image::load_from_memory(&b64(&r)).unwrap().to_rgba8().get_pixel(29, 19).0, [0, 0, 0, 255]); +} + +#[test] +fn save_for_office_writes_a_png_at_the_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "artboards": 2})).unwrap(); + let r = s.execute("document.exportForOffice", &json!({"ppi": 150})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1275), Some(1650)), "Letter at 150 ppi"); + let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (1275, 1650)); + assert_eq!(img.get_pixel(0, 0).0, [255, 255, 255, 255], "on white"); + let r = s.execute("document.exportForOffice", &json!({"ppi": 72, "transparent": true, "artboard": 1})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(612), Some(792))); + assert_eq!(image::load_from_memory(&b64(&r)).unwrap().to_rgba8().get_pixel(0, 0)[3], 0, "transparent"); + assert!(s.execute("document.exportForOffice", &json!({"artboard": 2})).is_err()); + assert!(s.execute("document.exportForOffice", &json!({"ppi": -3})).is_err()); + let spec = crate::cmd::find_command("document.exportForOffice").unwrap(); + assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..])); +} diff --git a/crates/engine/src/cmd/fileio/text.rs b/crates/engine/src/cmd/fileio/text.rs new file mode 100644 index 000000000..7e01a775a --- /dev/null +++ b/crates/engine/src/cmd/fileio/text.rs @@ -0,0 +1,96 @@ +//! Export As Text (`.txt`): the document's stories, one after another in stacking order (back to +//! front), as UTF-8 or UTF-16 with LF or CRLF line endings. Threaded frames are one story, +//! written where its first frame is; hidden objects and template layers are left out. + +use std::collections::HashSet; + +use serde::Deserialize; +use serde_json::Value; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; + +use super::super::*; +use super::FormatOption; + +const C: &str = "document.export"; + +const ENCODING: FormatOption = FormatOption { + name: "encoding", + ty: "string", + default: "\"utf8\"", + description: "utf8 (no byte order mark) or utf16 (little-endian, with a byte order mark)", +}; +const LINE_ENDINGS: FormatOption = + FormatOption { name: "lineEndings", ty: "string", default: "\"lf\"", description: "lf (macOS, Linux) or crlf (Windows)" }; +const SELECTION_ONLY: FormatOption = + FormatOption { name: "selectionOnly", ty: "boolean", default: "false", description: "only the selected type objects (document.export)" }; + +/// The options `document.formats` lists for text. +pub(super) const OPTIONS: &[FormatOption] = &[ENCODING, LINE_ENDINGS, SELECTION_ONLY]; + +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct TextOptions { + encoding: Option, + line_endings: Option, +} + +/// The text file of `doc` with the text options of `p`. +pub(super) fn encode(doc: &Document, p: &Value) -> Result> { + let o = if p.is_object() { TextOptions::deserialize(p).map_err(|e| bad(C, format!("Text options: {e}")))? } else { TextOptions::default() }; + let eol = match o.line_endings.as_deref().map(str::to_ascii_lowercase).as_deref() { + None | Some("lf") => "\n", + Some("crlf") => "\r\n", + Some(other) => return Err(bad(C, format!("lineEndings `{other}`: lf or crlf"))), + }; + let mut text = String::new(); + for story in stories(doc) { + // Any line break in the story becomes the chosen one; every story ends with one. + let story = story.replace("\r\n", "\n").replace('\r', "\n"); + for line in story.split('\n') { + text.push_str(line); + text.push_str(eol); + } + } + match o.encoding.as_deref().map(|e| e.to_ascii_lowercase().replace(['-', '_'], "")).as_deref() { + None | Some("utf8") => Ok(text.into_bytes()), + Some("utf16" | "utf16le" | "unicode") => Ok([0xFF, 0xFE].into_iter().chain(text.encode_utf16().flat_map(u16::to_le_bytes)).collect()), + Some(_) => Err(bad(C, format!("encoding `{}`: utf8 or utf16", o.encoding.unwrap_or_default()))), + } +} + +/// The text of every visible story in stacking order (back to front). +fn stories(doc: &Document) -> Vec { + let mut out = vec![]; + let mut done = HashSet::new(); + for l in &doc.layers { + collect(doc, l, &mut done, &mut out); + } + out +} + +fn collect(doc: &Document, n: &Node, done: &mut HashSet, out: &mut Vec) { + if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { + return; + } + if let NodeKind::Text(t) = &n.kind { + if done.contains(&n.id) { + return; + } + // A threaded frame: the whole thread's story, once. + match doc.text_threads.iter().find(|th| th.contains(&n.id)) { + Some(thread) => { + done.extend(thread.iter().copied()); + let frames = thread.iter().filter_map(|id| doc.node(*id)).filter_map(|f| match &f.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }); + out.push(frames.collect()); + } + None => out.push(t.plain_text()), + } + return; + } + for c in n.children().into_iter().flatten() { + collect(doc, c, done, out); + } +} diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index a0ecfa7f3..0512d3ce7 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; - assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"])); + assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"])); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3749bb3a0..f954a2d42 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 98eaa2680..0c06f79b3 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -20,6 +20,8 @@ vello_cpu = { workspace = true } vello_common = { version = "0.2", default-features = false } image = { workspace = true } flate2 = { workspace = true } +jpeg-encoder = { workspace = true } +gif = { workspace = true } log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/render/src/encode/gif.rs b/crates/render/src/encode/gif.rs new file mode 100644 index 000000000..3d9760895 --- /dev/null +++ b/crates/render/src/encode/gif.rs @@ -0,0 +1,33 @@ +//! GIF export: a palette image ([`super::quantize`]) as a one-frame GIF89a with its transparent +//! entry, optionally interlaced (rows in four passes, so the image builds up while it loads). + +use std::borrow::Cow; + +use super::quantize::Indexed; + +/// Encode `img` as a GIF file. +pub fn encode(img: &Indexed, interlaced: bool) -> Result, String> { + let fail = |e: gif::EncodingError| format!("GIF encoding failed: {e}"); + let (Ok(w), Ok(h)) = (u16::try_from(img.width), u16::try_from(img.height)) else { + return Err(format!("{} × {} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution", img.width, img.height)); + }; + if w == 0 || h == 0 || img.indices.len() != w as usize * h as usize || img.palette.is_empty() || img.palette.len() > 256 { + return Err("GIF encoding failed: the palette image is malformed".into()); + } + let buffer = if interlaced { Cow::Owned(interlace(&img.indices, w as usize)) } else { Cow::Borrowed(img.indices.as_slice()) }; + let mut enc = gif::Encoder::new(Vec::new(), w, h, &img.palette.concat()).map_err(fail)?; + let frame = gif::Frame { width: w, height: h, buffer, transparent: img.transparent, interlaced, ..gif::Frame::default() }; + enc.write_frame(&frame).map_err(fail)?; + enc.into_inner().map_err(fail) +} + +/// The rows of a `width`-wide image in GIF's interlaced order: every 8th row from 0, every 8th +/// from 4, every 4th from 2, then every 2nd from 1. +fn interlace(px: &[u8], width: usize) -> Vec { + let rows: Vec<&[u8]> = px.chunks(width.max(1)).collect(); + [(0, 8), (4, 8), (2, 4), (1, 2)] + .into_iter() + .flat_map(|(start, step)| rows.iter().skip(start).step_by(step)) + .flat_map(|r| r.iter().copied()) + .collect() +} diff --git a/crates/render/src/encode/jpeg.rs b/crates/render/src/encode/jpeg.rs new file mode 100644 index 000000000..388a47eb5 --- /dev/null +++ b/crates/render/src/encode/jpeg.rs @@ -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 { + 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 2a3eef2af..d886e76e6 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -1,8 +1,12 @@ //! 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), lossless WebP, or +//! a palette image ([`quantize`]) as PNG-8 or [`gif`]. +pub mod gif; +pub mod jpeg; pub mod png; +pub mod quantize; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; @@ -15,6 +19,9 @@ pub enum RasterFormat { Png, Jpeg, WebP, + /// Indexed PNG: a palette of up to 256 colours. + Png8, + Gif, } /// How a raster export renders and encodes. @@ -25,15 +32,27 @@ pub struct RasterExportOptions { /// Opaque background colour (RGB); `None` = transparent (JPEG has no alpha: white). pub background: Option<[u8; 3]>, pub anti_alias: AntiAlias, - /// PNG: Adam7 interlacing. + /// PNG, PNG-8: Adam7 interlacing; GIF: interlaced rows. pub interlaced: bool, - /// JPEG quality 1–100. + /// JPEG quality 0–100. pub quality: u8, + /// JPEG colour model, coding and profile. + pub jpeg: jpeg::JpegOptions, + /// PNG-8 and GIF: the colour reduction. + pub palette: quantize::PaletteOptions, } 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(), + palette: quantize::PaletteOptions::default(), + } } } @@ -55,49 +74,52 @@ 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), + RasterFormat::Png8 | RasterFormat::Gif => { + let ix = quantize::quantize(&img.to_straight(), img.width, img.height, &self.palette); + match format { + RasterFormat::Gif => gif::encode(&ix, self.interlaced), + _ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }), + } + } } } } 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 +153,7 @@ fn drawn_bounds(n: &Node) -> Option { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_jpeg; +#[cfg(test)] +mod tests_palette; diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index 7b703e324..1fb04571a 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -1,6 +1,7 @@ -//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque), adaptive row -//! filters, the resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up -//! progressively while it loads). Chunks are written here; flate2 compresses the image data. +//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque) or indexed +//! (PNG-8: a palette of up to 256 colours, 1–8 bits per pixel), adaptive row filters, the +//! resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up progressively +//! while it loads). Chunks are written here; flate2 compresses the image data. use std::borrow::Cow; use std::io::Write as _; @@ -8,6 +9,8 @@ use std::io::Write as _; use flate2::Crc; use flate2::write::ZlibEncoder; +use super::quantize::Indexed; + /// What goes into the file besides the pixels. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct PngOptions { @@ -39,9 +42,7 @@ pub fn phys_chunk(x: f64, y: f64) -> Vec { /// Encode straight-alpha RGBA8 pixels (`width`×`height`, row-major) as a PNG file. pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result, String> { - if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 { - return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size")); - } + check_size(width, height)?; if rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 { return Err("PNG encoding failed: pixel buffer doesn't match the image size".into()); } @@ -49,32 +50,82 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result = if opaque { Cow::Owned(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect()) } else { Cow::Borrowed(rgba) }; + write(&pixels, width, height, Layout { channels, bits: 8, color_type }, &[], o) +} +/// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its +/// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs. +pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result, String> { + check_size(img.width, img.height)?; + if img.indices.len() as u64 != u64::from(img.width) * u64::from(img.height) || img.palette.is_empty() || img.palette.len() > 256 { + return Err("PNG encoding failed: the palette image is malformed".into()); + } + let bits = match img.palette.len() { + 0..=2 => 1, + 3..=4 => 2, + 5..=16 => 4, + _ => 8, + }; + let plte = img.palette.concat(); + // Alpha of the entries up to the transparent one (the rest are opaque). + let trns: Vec = img.transparent.map(|t| (0..=t).map(|i| if i == t { 0 } else { 255 }).collect()).unwrap_or_default(); + let mut extra: Vec<(&[u8; 4], &[u8])> = vec![(b"PLTE", &plte)]; + if !trns.is_empty() { + extra.push((b"tRNS", &trns)); + } + write(&img.indices, img.width, img.height, Layout { channels: 1, bits, color_type: 3 }, &extra, o) +} + +fn check_size(width: u32, height: u32) -> Result<(), String> { + if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 { + return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size")); + } + Ok(()) +} + +/// How pixels are stored: bytes per pixel (one per index for palette images), bits per sample +/// in the file and the PNG colour type. +#[derive(Clone, Copy)] +struct Layout { + channels: usize, + bits: u8, + color_type: u8, +} + +/// Write the file: signature, `IHDR`, `pHYs`, the `extra` chunks, the filtered and compressed +/// pixels (`layout.channels` bytes each, packed to `layout.bits` in the file) and `IEND`. +fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8; 4], &[u8])], o: &PngOptions) -> Result, String> { + let Layout { channels, bits, color_type } = layout; let mut out = Vec::with_capacity(pixels.len() / 2 + 64); out.extend_from_slice(SIGNATURE); let mut ihdr = Vec::with_capacity(13); ihdr.extend_from_slice(&width.to_be_bytes()); ihdr.extend_from_slice(&height.to_be_bytes()); - // Bit depth 8, colour type, deflate compression, adaptive filtering, interlace method. - ihdr.extend_from_slice(&[8, color_type, 0, 0, u8::from(o.interlaced)]); + // Bit depth, colour type, deflate compression, adaptive filtering, interlace method. + ihdr.extend_from_slice(&[bits, color_type, 0, 0, u8::from(o.interlaced)]); chunk(&mut out, b"IHDR", &ihdr); if let Some(ppi) = o.ppi { out.extend_from_slice(&phys_chunk(ppi, ppi)); } - let raw = if o.interlaced { - let mut raw = Vec::with_capacity(pixels.len() + height as usize * 2); + for (ty, data) in extra { + chunk(&mut out, ty, data); + } + // Filters work on whole bytes: a sub-byte pixel counts as one. + let bpp = (channels * bits as usize / 8).max(1); + let mut raw = Vec::with_capacity(pixels.len() + height as usize * if o.interlaced { 2 } else { 1 }); + let mut rows = |px: &[u8], w: u32, h: u32| { + let packed = pack(px, w, h, bits); + filter_rows(&packed, (w as usize * channels * bits as usize).div_ceil(8), h, bpp, &mut raw); + }; + if o.interlaced { for pass in ADAM7 { - let sub = sub_image(&pixels, width, height, channels, pass); - if let Some((w, h, px)) = sub { - filter_rows(&px, w, h, channels, &mut raw); + if let Some((w, h, px)) = sub_image(pixels, width, height, channels, pass) { + rows(&px, w, h); } } - raw } else { - let mut raw = Vec::with_capacity(pixels.len() + height as usize); - filter_rows(&pixels, width, height, channels, &mut raw); - raw - }; + rows(pixels, width, height); + } let mut z = ZlibEncoder::new(Vec::with_capacity(raw.len() / 2), flate2::Compression::default()); let data = z.write_all(&raw).and_then(|()| z.finish()).map_err(|e| format!("PNG encoding failed: {e}"))?; for part in data.chunks(IDAT_MAX) { @@ -122,6 +173,23 @@ pub fn with_text(png: Vec, entries: &[(&str, Cow)]) -> Vec { out } +/// Rows of one-byte pixels packed `bits` per pixel, most significant first (each row starts on a +/// byte); at 8 bits the pixels as they are. +fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> { + if bits >= 8 { + return Cow::Borrowed(px); + } + let per = (8 / bits) as usize; + let mut out = Vec::with_capacity(h as usize * (w as usize).div_ceil(per)); + for row in px.chunks(w.max(1) as usize) { + for group in row.chunks(per) { + let byte = group.iter().enumerate().fold(0u8, |b, (i, v)| b | (v & ((1 << bits) - 1)) << (8 - bits as usize * (i + 1))); + out.push(byte); + } + } + Cow::Owned(out) +} + /// Append one chunk: length, type, data, CRC of type and data. fn chunk(out: &mut Vec, ty: &[u8; 4], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); @@ -150,10 +218,9 @@ fn sub_image(px: &[u8], width: u32, height: u32, channels: usize, (x0, y0, dx, d Some((w, h, out)) } -/// Filter every row of a `w`×`h` image into `out` (filter type byte + filtered bytes), picking -/// per row the filter with the smallest sum of absolute values (the usual heuristic). -fn filter_rows(px: &[u8], w: u32, h: u32, bpp: usize, out: &mut Vec) { - let stride = w as usize * bpp; +/// Filter every row (`stride` bytes, `h` rows) into `out` (filter type byte + filtered bytes), +/// picking per row the filter with the smallest sum of absolute values (the usual heuristic). +fn filter_rows(px: &[u8], stride: usize, h: u32, bpp: usize, out: &mut Vec) { let zero = vec![0u8; stride]; let mut cand: [Vec; 5] = std::array::from_fn(|_| vec![0u8; stride]); for y in 0..h as usize { diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs new file mode 100644 index 000000000..e51c2b0fa --- /dev/null +++ b/crates/render/src/encode/quantize.rs @@ -0,0 +1,447 @@ +//! Colour reduction for palette formats (PNG-8, GIF): a palette of at most 256 colours chosen by +//! median cut over a 15-bit histogram (refined by a few k-means passes) or a fixed palette, then +//! each pixel mapped to its nearest entry with optional dithering (Floyd–Steinberg diffusion, an +//! 8×8 ordered pattern, or noise). Pixels under half opacity can become one transparent entry; +//! the others are blended over the matte colour. + +use std::collections::HashMap; + +/// How the palette is chosen. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Reduction { + /// Median cut weighted towards the colours the eye tells apart best. + Perceptual, + /// Like perceptual, keeping rarer distinct colours too, and snapping near web colours to them. + #[default] + Selective, + /// Median cut by how often colours occur. + Adaptive, + /// The 216 web-safe colours (the most used of them when fewer are allowed). + Web, + /// Black and white. + BlackWhite, + /// Evenly spaced greys. + Gray, +} + +impl Reduction { + pub const ALL: [Reduction; 6] = + [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::BlackWhite, Reduction::Gray]; + + pub fn id(self) -> &'static str { + match self { + Reduction::Perceptual => "perceptual", + Reduction::Selective => "selective", + Reduction::Adaptive => "adaptive", + Reduction::Web => "web", + Reduction::BlackWhite => "blackWhite", + Reduction::Gray => "gray", + } + } + + pub fn label(self) -> &'static str { + match self { + Reduction::Perceptual => "Perceptual", + Reduction::Selective => "Selective", + Reduction::Adaptive => "Adaptive", + Reduction::Web => "Web", + Reduction::BlackWhite => "Black & White", + Reduction::Gray => "Grayscale", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|r| r.id().eq_ignore_ascii_case(s) || r.label().eq_ignore_ascii_case(s)) + } + + /// Channel weights of colour distances. + fn weights(self) -> [f32; 3] { + match self { + Reduction::Perceptual | Reduction::Selective => [0.55, 0.77, 0.34], + _ => [1.0; 3], + } + } +} + +/// How colours between palette entries are approximated. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Dither { + None, + /// Floyd–Steinberg error diffusion. + #[default] + Diffusion, + /// An 8×8 ordered (Bayer) pattern. + Pattern, + /// Random noise. + Noise, +} + +impl Dither { + pub const ALL: [Dither; 4] = [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise]; + + pub fn id(self) -> &'static str { + match self { + Dither::None => "none", + Dither::Diffusion => "diffusion", + Dither::Pattern => "pattern", + Dither::Noise => "noise", + } + } + + pub fn label(self) -> &'static str { + match self { + Dither::None => "No Dither", + Dither::Diffusion => "Diffusion", + Dither::Pattern => "Pattern", + Dither::Noise => "Noise", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s) || d.label().eq_ignore_ascii_case(s)) + } +} + +/// The palette options of a raster export (PNG-8, GIF). +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteOptions { + /// Most palette entries, the transparent one included: 2–256. + pub colors: u16, + pub reduction: Reduction, + pub dither: Dither, + /// Dither strength 0–100. + pub dither_amount: u8, + /// Pixels under half opacity become a transparent entry; otherwise every pixel is opaque. + pub transparency: bool, + /// The colour partly transparent pixels are blended over. `None`: with transparency, they + /// keep their own colour (hard edges); without, white. + pub matte: Option<[u8; 3]>, +} + +impl Default for PaletteOptions { + fn default() -> Self { + Self { + colors: 256, + reduction: Reduction::Selective, + dither: Dither::Diffusion, + dither_amount: 100, + transparency: true, + matte: Some([255; 3]), + } + } +} + +/// An image as palette indices. +#[derive(Clone, Debug, PartialEq)] +pub struct Indexed { + pub width: u32, + pub height: u32, + pub palette: Vec<[u8; 3]>, + /// The transparent entry (always 0 when there is one). + pub transparent: Option, + /// One index per pixel, row-major. + pub indices: Vec, +} + +/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette. +pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed { + let px = rgba.as_chunks::<4>().0; + let clear = |p: &[u8; 4]| o.transparency && p[3] < 128; + let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite); + let matte = o.matte.unwrap_or([255; 3]); + let blend = o.matte.is_some() || !o.transparency; + // The opaque colours: blended over the matte, greys for the grey reductions. + let colors: Vec<[u8; 3]> = px + .iter() + .map(|p| { + let a = if blend { p[3] as u32 } else { 255 }; + let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8); + if gray { [luma(c); 3] } else { c } + }) + .collect(); + let has_clear = px.iter().any(clear); + let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1); + let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c); + let (mut palette, exact) = match o.reduction { + Reduction::BlackWhite => (vec![[0; 3], [255; 3]], false), + Reduction::Gray => ((0..k).map(|i| [(i * 255 / (k - 1).max(1)) as u8; 3]).collect(), false), + Reduction::Web => (web(opaque(), k), false), + r => match distinct(opaque(), k) { + Some(exact) => (exact, true), + None => (median_cut(opaque(), k, r), false), + }, + }; + if palette.is_empty() { + palette.push(matte); + } + let weights = o.reduction.weights(); + let dither = if exact { Dither::None } else { o.dither }; + let mut indices = map(&colors, width as usize, &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); + // Drop the entries no pixel uses (fixed palettes), keeping the order. + let mut used = [false; 256]; + for (i, &ix) in indices.iter().enumerate() { + if !clear(&px[i]) { + used[ix as usize] = true; + } + } + let mut remap = [0u8; 256]; + let mut kept = Vec::with_capacity(palette.len() + 1); + if has_clear { + kept.push(matte); + } + for (i, c) in palette.iter().enumerate() { + if used[i] { + remap[i] = kept.len() as u8; + kept.push(*c); + } + } + for (i, ix) in indices.iter_mut().enumerate() { + *ix = if clear(&px[i]) { 0 } else { remap[*ix as usize] }; + } + Indexed { width, height, palette: kept, transparent: has_clear.then_some(0), indices } +} + +/// Lightness as a grey value (Rec. 601 luma). +fn luma([r, g, b]: [u8; 3]) -> u8 { + ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114 + 500) / 1000) as u8 +} + +/// The colours themselves, most used first, when there are at most `k`. +fn distinct(colors: impl Iterator, k: usize) -> Option> { + let mut seen: HashMap<[u8; 3], u32> = HashMap::with_capacity(k + 1); + for c in colors { + *seen.entry(c).or_insert(0) += 1; + if seen.len() > k { + return None; + } + } + let mut v: Vec<([u8; 3], u32)> = seen.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + Some(v.into_iter().map(|(c, _)| c).collect()) +} + +/// A web-safe colour: each channel a multiple of 51. +fn web_safe(c: [u8; 3]) -> [u8; 3] { + c.map(|v| ((v as u32 + 25) / 51 * 51) as u8) +} + +/// The `k` most used web-safe colours. +fn web(colors: impl Iterator, k: usize) -> Vec<[u8; 3]> { + let mut count: HashMap<[u8; 3], u32> = HashMap::new(); + for c in colors { + *count.entry(web_safe(c)).or_insert(0) += 1; + } + let mut v: Vec<([u8; 3], u32)> = count.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + v.into_iter().take(k).map(|(c, _)| c).collect() +} + +/// One histogram cell: its mean colour and pixel count. +#[derive(Clone, Copy)] +struct Cell { + mean: [f32; 3], + count: u32, +} + +fn dist(a: [f32; 3], b: [f32; 3], w: [f32; 3]) -> f32 { + (0..3).map(|i| (w[i] * (a[i] - b[i])).powi(2)).sum() +} + +/// `k` colours by median cut over a 5-bit-per-channel histogram, then k-means passes. +fn median_cut(colors: impl Iterator, k: usize, r: Reduction) -> Vec<[u8; 3]> { + let mut hist = vec![(0u32, [0u64; 3]); 1 << 15]; + for c in colors { + let h = &mut hist[(c[0] as usize >> 3) << 10 | (c[1] as usize >> 3) << 5 | c[2] as usize >> 3]; + h.0 += 1; + for (sum, v) in h.1.iter_mut().zip(c) { + *sum += v as u64; + } + } + let mut cells: Vec = hist.iter().filter(|h| h.0 > 0).map(|&(n, s)| Cell { mean: s.map(|v| v as f32 / n as f32), count: n }).collect(); + let w = r.weights(); + // How much a box gains from a split: its widest weighted range, times its pixel count (how + // often its colours occur) or a root of it (keeping rarer colours apart too). + let score = |b: &[Cell]| -> (f32, usize) { + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + let mut n = 0u64; + for c in b { + for i in 0..3 { + lo[i] = lo[i].min(c.mean[i]); + hi[i] = hi[i].max(c.mean[i]); + } + n += c.count as u64; + } + let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0); + let range = w[axis] * (hi[axis] - lo[axis]); + let weight = match r { + Reduction::Adaptive => n as f32, + Reduction::Perceptual => (n as f32).sqrt(), + _ => (n as f32).sqrt().sqrt(), + }; + (if b.len() < 2 { 0.0 } else { range * weight }, axis) + }; + // (start, end, score, axis) over `cells`. + let mut boxes = vec![{ + let (s, axis) = score(&cells); + (0, cells.len(), s, axis) + }]; + while boxes.len() < k { + let Some(at) = boxes.iter().enumerate().max_by(|x, y| x.1.2.total_cmp(&y.1.2)).map(|(i, _)| i) else { break }; + let (a, b, s, axis) = boxes[at]; + if s <= 0.0 { + break; + } + let part = &mut cells[a..b]; + part.sort_by(|x, y| x.mean[axis].total_cmp(&y.mean[axis])); + // Split at the median pixel, leaving at least one cell on each side. + let total: u64 = part.iter().map(|c| c.count as u64).sum(); + let mut acc = 0u64; + let mut mid = 1; + for (i, c) in part.iter().enumerate() { + acc += c.count as u64; + if acc * 2 >= total { + mid = (i + 1).clamp(1, part.len() - 1); + break; + } + } + for (x, y) in [(a, a + mid), (a + mid, b)] { + let (s, axis) = score(&cells[x..y]); + if x == a { + boxes[at] = (x, y, s, axis); + } else { + boxes.push((x, y, s, axis)); + } + } + } + let mean = |b: &[Cell]| { + let n: f64 = b.iter().map(|c| c.count as f64).sum(); + [0, 1, 2].map(|i| (b.iter().map(|c| c.mean[i] as f64 * c.count as f64).sum::() / n.max(1.0)) as f32) + }; + let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect(); + // k-means: move each entry to the mean of the cells nearest to it. + for _ in 0..3 { + let mut sums = vec![([0f64; 3], 0f64); palette.len()]; + for c in &cells { + let i = nearest(&palette, c.mean, w); + for ch in 0..3 { + sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64; + } + sums[i].1 += c.count as f64; + } + for (p, (s, n)) in palette.iter_mut().zip(sums) { + if n > 0.0 { + *p = s.map(|v| (v / n) as f32); + } + } + } + let mut out: Vec<[u8; 3]> = palette.iter().map(|p| p.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect(); + if r == Reduction::Selective { + // Colours close to a web-safe one become it (they display without dithering in a browser + // limited to those). + for c in &mut out { + let s = web_safe(*c); + if (0..3).all(|i| c[i].abs_diff(s[i]) <= 6) { + *c = s; + } + } + } + out.sort_unstable(); + out.dedup(); + out +} + +fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize { + palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i) +} + +/// The 8×8 ordered-dither threshold at (x, y), in 0..1. +fn bayer(x: usize, y: usize) -> f32 { + let mut v = 0; + for i in 0..3 { + let (xb, yb) = ((x >> i) & 1, (y >> i) & 1); + v |= ((xb ^ yb) << 1 | yb) << (2 * (2 - i)); + } + (v as f32 + 0.5) / 64.0 +} + +/// A noise threshold at (x, y), in 0..1 (a hash, so exports are reproducible). +fn noise(x: usize, y: usize) -> f32 { + let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77); + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 12; + (h >> 8) as f32 / (1 << 24) as f32 +} + +/// Map each colour to its nearest palette entry, dithering by `amount` (0–1). Pixels `skip` +/// names (transparent) get index 0 and take no diffused error. +fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec { + let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect(); + // Nearest entries by colour: exact colours without dithering, a 6-bit grid with it. + let mut exact: HashMap<[u8; 3], u8> = HashMap::new(); + let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }]; + let mut lookup = |c: [f32; 3]| -> u8 { + let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8); + if grid.is_empty() { + return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8); + } + let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2; + match grid.get(key) { + Some(&v) if v != u16::MAX => v as u8, + _ => { + let i = nearest(&pal, c, w) as u8; + if let Some(g) = grid.get_mut(key) { + *g = i as u16; + } + i + } + } + }; + // Ordered and noise dithering spread a pixel by about one palette step. + let step = 255.0 / (pal.len() as f32).cbrt().max(1.0) * amount; + let width = width.max(1); + let mut out = vec![0u8; colors.len()]; + let mut err = vec![[0f32; 3]; 2 * (width + 2)]; + for (y, row) in colors.chunks(width).enumerate() { + let (cur, next) = err.split_at_mut(width + 2); + if dither == Dither::Diffusion { + next.fill([0.0; 3]); + } + for (x, c) in row.iter().enumerate() { + let i = y * width + x; + if skip(i) { + continue; + } + let mut v = c.map(f32::from); + match dither { + Dither::None => {} + Dither::Diffusion => { + for ch in 0..3 { + v[ch] = (v[ch] + cur[x + 1][ch]).clamp(0.0, 255.0); + } + } + Dither::Pattern | Dither::Noise => { + let t = if dither == Dither::Pattern { bayer(x % 8, y % 8) } else { noise(x, y) }; + v = v.map(|ch| (ch + (t - 0.5) * step).clamp(0.0, 255.0)); + } + } + let ix = lookup(v); + out[i] = ix; + if dither == Dither::Diffusion { + let p = pal.get(ix as usize).copied().unwrap_or_default(); + let e = [0, 1, 2].map(|ch| (v[ch] - p[ch]) * amount); + // Floyd–Steinberg: 7/16 right, 3/16 below left, 5/16 below, 1/16 below right. + for ch in 0..3 { + cur[x + 2][ch] += e[ch] * 7.0 / 16.0; + next[x][ch] += e[ch] * 3.0 / 16.0; + next[x + 1][ch] += e[ch] * 5.0 / 16.0; + next[x + 2][ch] += e[ch] / 16.0; + } + } + } + if dither == Dither::Diffusion { + cur.copy_from_slice(next); + } + } + out +} diff --git a/crates/render/src/encode/tests_jpeg.rs b/crates/render/src/encode/tests_jpeg.rs new file mode 100644 index 000000000..32366ed5a --- /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/encode/tests_palette.rs b/crates/render/src/encode/tests_palette.rs new file mode 100644 index 000000000..53a139f3e --- /dev/null +++ b/crates/render/src/encode/tests_palette.rs @@ -0,0 +1,180 @@ +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::shapes; + +use super::png::{PngOptions, encode_indexed}; +use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize}; +use super::*; + +/// A `w`×`h` opaque image of `f(x, y)` colours. +fn image(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> Vec { + (0..h) + .flat_map(|y| (0..w).map(move |x| (x, y))) + .flat_map(|(x, y)| { + let [r, g, b] = f(x, y); + [r, g, b, 255] + }) + .collect() +} + +/// Smooth colour ramps: thousands of colours. +fn ramps(w: u32, h: u32) -> Vec { + image(w, h, |x, y| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) * 127 / (w + h)) as u8]) +} + +fn rgb(px: &[u8]) -> Vec<[u8; 3]> { + px.chunks(4).map(|p| [p[0], p[1], p[2]]).collect() +} + +/// The colour each pixel gets back. +fn colours(ix: &Indexed) -> Vec<[u8; 3]> { + ix.indices.iter().map(|&i| ix.palette[i as usize]).collect() +} + +/// The PNG chunk of type `ty` (first match). +fn chunk<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> { + let mut i = 8; + while i + 8 <= file.len() { + let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize; + if &file[i + 4..i + 8] == ty { + return Some(&file[i + 8..i + 8 + len]); + } + i += 12 + len; + } + None +} + +#[test] +fn two_colour_art_gives_two_entries() { + let px = image(9, 5, |x, _| if x < 4 { [200, 30, 40] } else { [10, 10, 90] }); + for reduction in [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive] { + let ix = quantize(&px, 9, 5, &PaletteOptions { reduction, ..Default::default() }); + assert_eq!(ix.palette.len(), 2, "{reduction:?}"); + assert_eq!(ix.transparent, None); + assert_eq!(colours(&ix), rgb(&px), "exact, no dithering"); + } + // A hard-edged circle exported on white: black and white only. + let mut d = Document::new(40.0, 30.0); + let id = d.alloc_id(); + let l = d.layers[0].id; + let n = Node::path(id, shapes::ellipse(Rect::new(5.3, 4.7, 31.1, 26.2)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + d.insert(Some(l), 0, n).unwrap(); + let o = RasterExportOptions { background: Some([255; 3]), anti_alias: AntiAlias::None, ..Default::default() }; + let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png8, &o).unwrap(); + assert_eq!(file[25], 3, "colour type 3"); + assert_eq!(file[24], 1, "two entries fit one bit per pixel"); + assert_eq!(chunk(&file, b"PLTE").unwrap().len(), 6); +} + +#[test] +fn colours_cap_the_palette() { + let px = ramps(64, 48); + for colors in [2, 16, 64, 256] { + for dither in Dither::ALL { + for reduction in [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::Gray] { + let ix = quantize(&px, 64, 48, &PaletteOptions { colors, dither, reduction, ..Default::default() }); + assert!(ix.palette.len() <= colors as usize, "{colors} {dither:?} {reduction:?}: {}", ix.palette.len()); + assert!(ix.indices.iter().all(|&i| (i as usize) < ix.palette.len())); + } + } + } + // Many colours use most of the palette. + assert!(quantize(&px, 64, 48, &PaletteOptions { colors: 16, ..Default::default() }).palette.len() >= 12); +} + +#[test] +fn fixed_palettes() { + let px = ramps(32, 32); + let web = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::Web, ..Default::default() }); + assert!(web.palette.iter().flatten().all(|v| v % 51 == 0), "web-safe"); + let gray = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::Gray, colors: 8, ..Default::default() }); + assert!(gray.palette.iter().all(|[r, g, b]| r == g && g == b) && gray.palette.len() <= 8); + let bw = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::BlackWhite, ..Default::default() }); + assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]); +} + +#[test] +fn dithering_keeps_the_average_colour() { + let px = image(64, 16, |x, _| [(x * 4) as u8; 3]); + let mean = |c: &[[u8; 3]]| c.iter().map(|p| p[0] as f64).sum::() / c.len().max(1) as f64; + // Per 8-pixel column band, how far the result's mean is from the original's. + let err = |dither| { + let ix = quantize(&px, 64, 16, &PaletteOptions { reduction: Reduction::BlackWhite, dither, ..Default::default() }); + let (got, want) = (colours(&ix), rgb(&px)); + let band = |c: &[[u8; 3]], b: usize| c.iter().enumerate().filter(|(i, _)| (i % 64) / 8 == b).map(|(_, p)| *p).collect::>(); + (0..8).map(|b| (mean(&band(&got, b)) - mean(&band(&want, b))).abs()).sum::() + }; + let none = err(Dither::None); + for d in [Dither::Diffusion, Dither::Pattern, Dither::Noise] { + assert!(err(d) < none / 2.0, "{d:?}: {} vs {none}", err(d)); + } + let noisy = PaletteOptions { dither: Dither::Noise, colors: 8, ..Default::default() }; + assert_eq!(quantize(&px, 64, 16, &noisy), quantize(&px, 64, 16, &noisy), "noise is reproducible"); +} + +#[test] +fn transparent_pixels_get_their_own_entry() { + let px: Vec = (0..16u8).flat_map(|i| if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, if i < 8 { 0 } else { 100 }] }).collect(); + let ix = quantize(&px, 4, 4, &PaletteOptions::default()); + assert_eq!(ix.transparent, Some(0)); + assert_eq!(ix.palette.len(), 2, "red and the transparent entry: alpha 100 is under half"); + assert!(ix.indices.iter().enumerate().all(|(i, &v)| (v == 0) == (i % 2 == 1))); + let opaque = quantize(&px, 4, 4, &PaletteOptions { transparency: false, matte: Some([0, 0, 0]), ..Default::default() }); + assert_eq!(opaque.transparent, None); + assert!(opaque.palette.contains(&[0, 0, 0]) && opaque.palette.contains(&[0, 0, 100]), "blended over the matte: {:?}", opaque.palette); + + let file = encode_indexed(&ix, &PngOptions::default()).unwrap(); + assert_eq!(chunk(&file, b"tRNS").unwrap(), [0]); + let back = image::load_from_memory(&file).unwrap().to_rgba8(); + assert_eq!(back.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(back.get_pixel(1, 0)[3], 0); +} + +#[test] +fn png8_decodes_at_every_bit_depth_and_interlaced() { + for (colors, depth) in [(2, 1), (4, 2), (16, 4), (200, 8)] { + let px = ramps(13, 11); + let ix = quantize(&px, 13, 11, &PaletteOptions { colors, ..Default::default() }); + for interlaced in [false, true] { + let file = encode_indexed(&ix, &PngOptions { ppi: Some(144.0), interlaced }).unwrap(); + assert_eq!((file[24], file[25], file[28]), (depth, 3, u8::from(interlaced))); + let back = image::load_from_memory(&file).unwrap().to_rgb8(); + let want: Vec = colours(&ix).into_iter().flatten().collect(); + assert_eq!(back.into_raw(), want, "{colors} colours, interlaced {interlaced}"); + assert!(chunk(&file, b"pHYs").is_some()); + } + } +} + +#[test] +fn gif_decodes() { + let px: Vec = ramps(21, 17).chunks(4).enumerate().flat_map(|(i, p)| [p[0], p[1], p[2], if i % 5 == 0 { 0 } else { 255 }]).collect(); + let ix = quantize(&px, 21, 17, &PaletteOptions { colors: 32, ..Default::default() }); + for interlaced in [false, true] { + let file = gif::encode(&ix, interlaced).unwrap(); + assert_eq!(&file[..6], b"GIF89a"); + let back = image::load_from_memory_with_format(&file, image::ImageFormat::Gif).unwrap().to_rgba8(); + assert_eq!(back.dimensions(), (21, 17)); + for (i, p) in back.pixels().enumerate() { + match ix.indices[i] { + 0 => assert_eq!(p[3], 0, "transparent at {i}"), + v => { + let [r, g, b] = ix.palette[v as usize]; + assert_eq!(p.0, [r, g, b, 255], "{i} interlaced {interlaced}"); + } + } + } + } + let wide = Indexed { width: 70_000, height: 1, palette: vec![[0; 3]], transparent: None, indices: vec![0; 70_000] }; + assert!(gif::encode(&wide, false).is_err()); +} + +#[test] +fn ids_round_trip() { + for r in Reduction::ALL { + assert_eq!(Reduction::from_id(r.id()), Some(r)); + } + for d in Dither::ALL { + assert_eq!(Dither::from_id(d.id()), Some(d)); + } +} diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 34136df91..8ca101d26 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 b6e9f6cb7..7d281d9fa 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/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 20695e14e..2b452b014 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -1,14 +1,14 @@ //! File → Export → Export As…: the format and Use Artboards (All or a range: one file per //! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK -//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP Options, SVG Options, -//! and Save PDF for artboards as pages), else the file is written. +//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG +//! Options, Text Export Options, and Save PDF for artboards as pages), else the file is written. use std::sync::LazyLock; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; -use super::{DialogSpec, form, png_options, save_pdf, svg_options}; +use super::{DialogSpec, form, png_options, save_pdf, svg_options, text_export}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -109,6 +109,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // Artboards as pages go on in Save PDF; the art's bounds are written straight away. "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), + "txt" => { + text_export::open(app, &path); + Ok(Value::Null) + } _ => { let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards }); if use_artboards { diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index b66fc5837..a670db461 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -102,7 +102,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } let cur = f["format"].as_str().unwrap_or("png").to_string(); egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| { - for fm in ["png", "jpg", "webp", "svg", "pdf"] { + for fm in ["png", "jpg", "webp", "gif", "png8", "svg", "pdf"] { if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() { f["format"] = json!(fm); } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index bcb16b886..b6c64ac70 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -31,6 +31,7 @@ pub mod missing_links; pub mod new_color_group; mod new_document; pub mod new_swatch; +pub mod office_export; mod path_ops; pub mod pdf_presets; pub mod place; @@ -48,6 +49,7 @@ pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; pub mod swatch_options; +mod text_export; pub mod text_import; pub mod tile_edge_color; mod tools; @@ -192,7 +194,7 @@ registry! { SvgOptions: [svg_options::KIND] => svg_options::SPEC, NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, Place: [place::KIND] => place::SPEC, - RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC, + RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC, ExportAs: ["exportAs"] => export_as::SPEC, ImportPdf: [import_pdf::KIND] => import_pdf::SPEC, SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC, @@ -203,6 +205,8 @@ registry! { PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, SaveOptions: [save_options::KIND] => save_options::SPEC, + TextExport: [text_export::KIND] => text_export::SPEC, + OfficeExport: [office_export::KIND] => office_export::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -308,3 +312,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/office_export.rs b/crates/ui-egui/src/dialogs/office_export.rs new file mode 100644 index 000000000..cd8e16780 --- /dev/null +++ b/crates/ui-egui/src/dialogs/office_export.rs @@ -0,0 +1,64 @@ +//! File → Save for Office Documents…: an artboard as a PNG for office documents and slides, at +//! 72, 150 or 300 ppi, on white or transparent. Fields are `document.exportForOffice` params. + +use serde_json::{Value, json}; + +use super::png_options::{RESOLUTION_LABELS, RESOLUTIONS}; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +pub(crate) const KIND: &str = "saveForOffice"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Save for Office Documents".into(), body, confirm, ok: Some("Save…"), min_width: 340.0, ..DialogSpec::FORM }; + +/// Open the dialog (150 ppi on white, the first artboard). +pub fn open(app: &mut VectorcraftApp) -> Result { + app.session.active().ok_or("no document")?; + app.ui.dialog = Some(Dialog::new(KIND, json!({"ppi": RESOLUTIONS[1], "transparent": false, "artboard": 0}))); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim)); + let Some(doc) = app.session.active().map(|st| &st.doc) else { return true }; + let ppi = d.f64("ppi", RESOLUTIONS[1]); + let board = (d.f64("artboard", 0.0).max(0.0) as usize).min(doc.artboards.len().saturating_sub(1)); + egui::Grid::new("office-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, "Resolution:"); + let at = RESOLUTIONS.iter().position(|r| *r == ppi).unwrap_or(1); + if let Some(i) = widgets::dropdown(ui, "office-ppi", RESOLUTION_LABELS[at], &RESOLUTION_LABELS[..RESOLUTIONS.len()], 160.0) + && let Some(r) = RESOLUTIONS.get(i) + { + d.fields.insert("ppi".into(), json!(r)); + } + ui.end_row(); + if doc.artboards.len() > 1 { + label(ui, "Artboard:"); + let names: Vec<&str> = doc.artboards.iter().map(|a| a.name.as_str()).collect(); + if let Some(i) = widgets::dropdown(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) { + d.fields.insert("artboard".into(), json!(i)); + } + ui.end_row(); + } + if let Some(a) = doc.artboards.get(board) { + label(ui, "Size:"); + let (w, h) = vectorcraft_render::region_pixels(a.rect, ppi / 72.0); + ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text)); + ui.end_row(); + } + }); + ui.add_space(8.0); + form::check(ui, d, "transparent", "Transparent Background"); + false +} + +/// Pick the file and write it (the command runs first: bad options keep the dialog open). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let path = io::save_command_output(app, "document.exportForOffice", "png", form::params(d))?; + app.ui.dialog = None; + Ok(json!({ "path": path })) +} diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 51feb18a0..c457b98b7 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,11 +1,15 @@ //! 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), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither, +//! transparency and matte). 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 vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; use super::{DialogSpec, form}; use crate::state::Dialog; @@ -15,15 +19,26 @@ use crate::{VectorcraftApp, io, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, ..DialogSpec::FORM }; /// Resolution presets (pixels per inch). -const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0]; +pub(super) const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0]; /// Their labels, then `Other` (any resolution). -const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"]; +pub(super) const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"]; /// Background param values. 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)"]; +/// Palette sizes offered (any 2–256 can be typed). +const COLOR_COUNTS: [&str; 8] = ["2", "4", "8", "16", "32", "64", "128", "256"]; +/// Matte param values and their labels (`Other`: a colour). +const MATTES: [&str; 3] = ["none", "white", "black"]; +const MATTE_LABELS: [&str; 4] = ["None", "White", "Black", "Other"]; +/// 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`). +/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`, `gifOptions`, +/// `png8Options`). fn kind(f: &Format) -> String { format!("{}Options", f.id) } @@ -43,10 +58,37 @@ 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])), + ]); + } + "gif" | "png8" => { + let p = PaletteOptions::default(); + o.extend([ + ("reduction".into(), json!(p.reduction.id())), + ("colors".into(), json!(p.colors)), + ("dither".into(), json!(p.dither.id())), + ("ditherAmount".into(), json!(p.dither_amount)), + ("transparency".into(), json!(p.transparency)), + ("matte".into(), json!(MATTES[1])), + ("interlaced".into(), json!(false)), + ]); + } + _ => {} + } if let Some((w, h)) = size { o.insert("__size".into(), json!([w, h])); } @@ -109,11 +151,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("background".into(), json!(value)); } if choice == 3 { - let c = vectorcraft_color::Color::from_hex(&bg).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0)); - let mut rgb = [c[0], c[1], c[2]]; - if ui.color_edit_button_srgb(&mut rgb).changed() { - d.fields.insert("background".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); - } + color_button(ui, d, "background"); } }); ui.end_row(); @@ -131,12 +169,11 @@ 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)); - } + "jpg" => jpeg_rows(ui, d, &label), + "gif" | "png8" => palette_rows(ui, d, &label), + "webp" => { + ui.label(""); + label(ui, "Lossless (lossy WebP isn't available yet)"); ui.end_row(); } _ => {} @@ -154,6 +191,118 @@ 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(); +} + +/// The PNG-8 and GIF Options rows: the palette (reduction, colours, dither and amount), +/// transparency and matte, interlacing. +fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) { + label(ui, "Color Reduction:"); + choice(ui, d, "reduction", &Reduction::ALL.map(Reduction::id), &Reduction::ALL.map(Reduction::label)); + ui.end_row(); + + label(ui, "Colors:"); + let mut n = d.f64("colors", 256.0).round().clamp(2.0, 256.0) as u16; + ui.horizontal(|ui| { + if let Some(i) = widgets::dropdown(ui, "ro-colors", &n.to_string(), &COLOR_COUNTS, 70.0) { + n = COLOR_COUNTS.get(i).and_then(|c| c.parse().ok()).unwrap_or(n); + d.fields.insert("colors".into(), json!(n)); + } + if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() { + d.fields.insert("colors".into(), json!(n)); + } + }); + ui.end_row(); + + label(ui, "Dither:"); + ui.horizontal(|ui| { + choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label)); + if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) { + let mut amount = d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0) as u8; + if ui.add(egui::DragValue::new(&mut amount).range(0..=100).suffix("%")).changed() { + d.fields.insert("ditherAmount".into(), json!(amount)); + } + } + }); + ui.end_row(); + + label(ui, "Matte:"); + let matte = d.str("matte"); + let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3); + ui.horizontal(|ui| { + if let Some(i) = widgets::dropdown(ui, "ro-matte", MATTE_LABELS[at], &MATTE_LABELS, 150.0) { + d.fields.insert("matte".into(), json!(MATTES.get(i).copied().unwrap_or("#808080"))); + } + if at == 3 { + color_button(ui, d, "matte"); + } + }); + ui.end_row(); + + ui.label(""); + ui.horizontal(|ui| { + form::check(ui, d, "transparency", "Transparency"); + form::check(ui, d, "interlaced", "Interlaced"); + }); + ui.end_row(); +} + +/// A colour button bound to `d.fields[key]` (`"#rrggbb"`; grey when unset). +pub(super) fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let c = vectorcraft_color::Color::from_hex(&d.str(key)).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0)); + let mut rgb = [c[0], c[1], c[2]]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert(key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } +} + +/// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown). +pub(super) 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 42e84016d..8d5b5429a 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 000000000..dcef576a2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -0,0 +1,135 @@ +//! The options of the export formats beyond PNG, drawn headlessly: JPEG, PNG-8, GIF and WebP, Text +//! Export Options and Save for Office Documents. + +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"); +} + +#[test] +fn palette_options_reduce_the_colours() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "gif"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("gifOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((spec(&d.kind).heading)(d), "GIF Options"); + assert_eq!( + (d.str("reduction"), d.f64("colors", 0.0), d.str("dither"), d.bool("transparency"), d.str("matte")), + ("selective".into(), 256.0, "diffusion".into(), true, "white".into()) + ); + frame(&mut app); + set(&mut app, "colors", json!(4)); + set(&mut app, "dither", json!("pattern")); + set(&mut app, "matte", json!("#336699")); + set(&mut app, "interlaced", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + let (path, gif) = written.borrow()[0].clone(); + assert_eq!(path, "/out/Untitled-1.gif"); + assert_eq!(&gif[..6], b"GIF89a"); + assert_eq!(gif[10] & 0x07, 1, "a global colour table of 4 entries"); + + app.run("file.exportAs", json!({"format": "png8"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("png8Options")); + set(&mut app, "reduction", json!("gray")); + frame(&mut app); + confirm(&mut app).unwrap(); + let (path, png) = written.borrow()[1].clone(); + assert_eq!(path, "/out/Untitled-1.png"); + assert_eq!(png[25], 3, "indexed"); +} + +#[test] +fn text_export_options_write_the_stories() { + let (mut app, written) = app(1); + app.run("text.create", json!({"x": 5, "y": 30, "text": "Hello\nthere"})).unwrap(); + app.run("file.exportAs", json!({"format": "txt"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("txtOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("path"), d.str("encoding")), ("/out/Untitled-1.txt".into(), "utf8".into())); + frame(&mut app); + set(&mut app, "lineEndings", json!("crlf")); + set(&mut app, "encoding", json!("bogus")); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some("txtOptions"), "a bad option keeps the dialog open"); + set(&mut app, "encoding", json!("utf8")); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.txt"); + assert!(app.ui.dialog.is_none()); + let (path, bytes) = written.borrow().last().cloned().unwrap(); + assert_eq!((path.as_str(), bytes.as_slice()), ("/out/Untitled-1.txt", &b"Hello\r\nthere\r\n"[..])); +} + +#[test] +fn webp_options_say_the_file_is_lossless() { + let (mut app, _) = app(1); + app.run("file.exportAs", json!({"format": "webp"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("webpOptions")); + frame(&mut app); +} + +#[test] +fn save_for_office_documents_picks_the_resolution() { + let (mut app, written) = app(2); + let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("document.exportForOffice")).unwrap(); + assert_eq!((item.path[0].as_str(), item.label.as_str(), item.enabled), ("File", "Save for Office Documents…", true)); + app.run("document.exportForOffice", json!({})).unwrap(); + assert_eq!(kind(&app), Some("saveForOffice")); + frame(&mut app); + set(&mut app, "ppi", json!(300)); + set(&mut app, "artboard", json!(1)); + set(&mut app, "transparent", json!(true)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.png"); + let img = image::load_from_memory(&written.borrow()[0].1).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (250, 167), "60 × 40 pt at 300 ppi"); + assert_eq!(img.get_pixel(0, 0)[3], 0, "transparent"); + // With a path (agents), no dialog. + let r = app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap(); + assert_eq!(r["path"], "/x/o.png"); + assert!(app.ui.dialog.is_none()); + let img = image::load_from_memory(&written.borrow()[1].1).unwrap().to_rgba8(); + assert_eq!((img.dimensions(), img.get_pixel(0, 0).0), ((60, 40), [255, 255, 255, 255])); +} diff --git a/crates/ui-egui/src/dialogs/text_export.rs b/crates/ui-egui/src/dialogs/text_export.rs new file mode 100644 index 000000000..748942c7f --- /dev/null +++ b/crates/ui-egui/src/dialogs/text_export.rs @@ -0,0 +1,52 @@ +//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a +//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…). + +use serde_json::{Value, json}; + +use super::png_options::choice; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io}; + +pub(crate) const KIND: &str = "txtOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Text Export Options".into(), body, confirm, ok: Some("Export"), min_width: 340.0, ..DialogSpec::FORM }; + +const ENCODINGS: [&str; 2] = ["utf8", "utf16"]; +const ENCODING_LABELS: [&str; 2] = ["UTF-8", "UTF-16 (Unicode)"]; +const LINE_ENDINGS: [&str; 2] = ["lf", "crlf"]; +const LINE_ENDING_LABELS: [&str; 2] = ["LF (macOS, Linux)", "CRLF (Windows)"]; + +/// Open the options for exporting the document's text to `path`. +pub fn open(app: &mut VectorcraftApp, path: &str) { + // The platform's own line endings by default. + let eol = if cfg!(windows) { LINE_ENDINGS[1] } else { LINE_ENDINGS[0] }; + let fields = json!({"format": "txt", "path": path, "encoding": ENCODINGS[0], "lineEndings": eol, "selectionOnly": false}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim)); + egui::Grid::new("text-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, "Encoding:"); + choice(ui, d, "encoding", &ENCODINGS, &ENCODING_LABELS); + ui.end_row(); + label(ui, "Line Endings:"); + choice(ui, d, "lineEndings", &LINE_ENDINGS, &LINE_ENDING_LABELS); + ui.end_row(); + }); + ui.add_space(8.0); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", "Selection Only")); + false +} + +/// Write the text file (the command runs first, so a bad option keeps the dialog open). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let path = io::save_command_output(app, "document.export", "txt", form::params(d))?; + app.ui.dialog = None; + Ok(json!({ "path": path })) +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2ed6ee410..b29976dd8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -790,6 +790,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_pdf_preset(app, p), + // Save for Office Documents…: its dialog; with options, pick the file (or write `path`). + "document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app), + "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), _ => return None, }; Some(r) @@ -1057,6 +1060,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save a Copy…", "file.saveCopy"), c("Save as Template…", "file.saveAsTemplate"), c("Save as PDF…", "file.export.pdf"), + c("Save for Office Documents…", "document.exportForOffice"), c("Revert", "file.revert"), Sep, c("Place…", "file.place"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ec669659c..f71ea1cbe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,6 +26,10 @@ | 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`) | +| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | +| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | +| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` | | `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 | diff --git a/docs/mcp.md b/docs/mcp.md index 3e0dd2f6d..2a20832ce 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |