From ba4ed2ff932b5f5533ca9c388f514248de7462e5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 10:28:00 +0700 Subject: [PATCH 1/3] =?UTF-8?q?M4.48:=20JPEG=20Options=20=E2=80=94=20RGB/C?= =?UTF-8?q?MYK/gray,=20baseline/optimized/progressive,=20embedded=20ICC=20?= =?UTF-8?q?profiles=20and=20image=20maps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document.export {format: "jpg"} takes colorModel (rgb|cmyk|gray), quality 0–100, method (baseline|optimized|progressive), scans (3–5), embedIcc and imageMap (none|client|server). JPEGs are written with jpeg-encoder. CMYK files hold ink amounts drawn as the renderer's ink planes, so CMYK colours keep their inks (black type stays black ink alone). Profiles are made in code from the CMS (cms::icc_bytes): the RGB spaces from their primaries, the CMYK spaces as Lab lookup tables sampled from their model, a grey with sRGB's tone curve, and loaded profiles as loaded. Image maps write .html or .map beside the image for objects with a URL and an Image Map shape. The JPEG Options dialog gains Color Model, Quality 0–10, Method and Scans, Image Map and Embed ICC Profile; CMYK documents default to CMYK. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 + Cargo.toml | 1 + NOTICE | 3 + crates/color/src/cms/icc.rs | 127 +++++++++++- crates/color/src/cms/mod.rs | 31 +++ crates/color/src/cms/tests_icc.rs | 62 ++++++ crates/engine/src/cmd/fileio/encode.rs | 56 ++++- crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/imagemap.rs | 176 ++++++++++++++++ crates/engine/src/cmd/fileio/mod.rs | 53 ++++- crates/engine/src/cmd/fileio/tests_jpeg.rs | 89 ++++++++ crates/render/Cargo.toml | 1 + crates/render/src/encode/jpeg.rs | 196 ++++++++++++++++++ crates/render/src/encode/mod.rs | 55 ++--- crates/render/src/encode/tests_jpeg.rs | 156 ++++++++++++++ crates/render/src/ink.rs | 12 ++ crates/render/src/lib.rs | 24 ++- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/png_options.rs | 93 +++++++-- crates/ui-egui/src/dialogs/tests_export.rs | 10 +- .../src/dialogs/tests_raster_formats.rs | 44 ++++ docs/control-protocol.md | 1 + 22 files changed, 1129 insertions(+), 72 deletions(-) create mode 100644 crates/color/src/cms/tests_icc.rs create mode 100644 crates/engine/src/cmd/fileio/imagemap.rs create mode 100644 crates/engine/src/cmd/fileio/tests_jpeg.rs create mode 100644 crates/render/src/encode/jpeg.rs create mode 100644 crates/render/src/encode/tests_jpeg.rs create mode 100644 crates/ui-egui/src/dialogs/tests_raster_formats.rs diff --git a/Cargo.lock b/Cargo.lock index ac5f2cb1f..4fce31e5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1885,6 +1885,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + [[package]] name = "js-sys" version = "0.3.106" @@ -4239,6 +4245,7 @@ version = "0.1.1" dependencies = [ "flate2", "image", + "jpeg-encoder", "log", "proptest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index baf03ed07..db45e12a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ skrifa = "0.47" harfrust = "0.13" linesweeper = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } # ui egui = "0.36" diff --git a/NOTICE b/NOTICE index 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..9ddf104e3 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -1,16 +1,20 @@ //! One encoder per writable format. Each format's options are parsed, typed, from the export params //! (unknown keys are ignored, so one params object can carry the options of several formats). +use std::borrow::Cow; + use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; use vectorcraft_doc::{Artboard, Document}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::jpeg::{self, JpegOptions}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::super::*; use super::Format; +use super::imagemap::{self, MapKind}; const C: &str = "document.export"; @@ -65,6 +69,8 @@ pub struct Encoded { pub files: Vec<(Option, Vec)>, /// Images to write next to the file(s) (SVG `images: "link"`). pub linked: Vec, + /// Image maps, each written beside its image under the image's name (raster `imageMap`). + pub maps: Vec, /// The encoder's warnings (features approximated or left out, options not applied yet). pub warnings: Vec, } @@ -76,13 +82,13 @@ impl Encoded { /// Every file to write for the destination `path` (a path or a file name): the file itself /// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images - /// beside it under their own names. - pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, &'a [u8])> { + /// beside it under their own names, then the image maps as `{image stem}.html` or `.map`. + pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> { // Siblings keep the path's own separators (this runs on every platform and on the web). let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)]; let sibling = |name: &str| format!("{dir}{name}"); - let mut out: Vec<(String, &[u8])> = match self.files.as_slice() { - [(_, bytes)] => vec![(path.to_string(), bytes.as_slice())], + let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() { + [(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))], files => { let boards: Vec = files.iter().map(|(b, _)| b.unwrap_or(0)).collect(); let file = std::path::Path::new(&path[dir.len()..]); @@ -91,18 +97,28 @@ impl Encoded { super::artboard_file_names(doc, &boards) .into_iter() .zip(files) - .map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), bytes.as_slice())) + .map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), Cow::Borrowed(bytes.as_slice()))) .collect() } }; - out.extend(self.linked.iter().map(|l| (sibling(&l.name), l.bytes.as_slice()))); + let maps: Vec<(String, Cow<[u8]>)> = self + .maps + .iter() + .filter_map(|m| { + let image = std::path::Path::new(&out.get(m.file)?.0); + let name = image.file_name()?.to_string_lossy(); + Some((image.with_extension(m.kind.ext()).to_string_lossy().into_owned(), Cow::Owned(m.text(&name).into_bytes()))) + }) + .collect(); + out.extend(self.linked.iter().map(|l| (sibling(&l.name), Cow::Borrowed(l.bytes.as_slice())))); + out.extend(maps); out } /// The one file and the warnings, when the export wrote nothing else. fn single(self, f: &Format) -> Result<(Vec, Vec)> { - if !self.linked.is_empty() { - return Err(bad(C, format!("{} with linked images writes several files: use document.export or document.save", f.label))); + if !self.linked.is_empty() || !self.maps.is_empty() { + return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label))); } match <[_; 1]>::try_from(self.files) { Ok([(_, bytes)]) => Ok((bytes, self.warnings)), @@ -123,6 +139,11 @@ struct RasterOptions { background: Option, anti_alias: Option, interlaced: Option, + color_model: Option, + method: Option, + scans: Option, + embed_icc: Option, + image_map: Option, } impl RasterOptions { @@ -141,11 +162,22 @@ impl RasterOptions { }, anti_alias: self.anti_alias.as_deref().map(anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(), interlaced: self.interlaced.unwrap_or(false), - quality: self.quality.unwrap_or(90), + quality: self.quality.unwrap_or(90).min(100), + jpeg: JpegOptions { + color_model: parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?, + method: parse(self.method.as_deref(), jpeg::Method::from_id, "method", "baseline, optimized or progressive")?, + scans: self.scans.unwrap_or(3).clamp(*jpeg::SCANS.start(), *jpeg::SCANS.end()), + embed_icc: self.embed_icc.unwrap_or(true), + }, }) } } +/// An enum option by id (its default when absent). +fn parse(v: Option<&str>, from_id: fn(&str) -> Option, name: &str, ids: &str) -> Result { + v.map(|s| from_id(s).ok_or_else(|| bad(C, format!("{name} `{s}`: {ids}")))).transpose().map(Option::unwrap_or_default) +} + /// A raster background: `transparent` (also `none` or null), `white`, `black` or a colour /// (`"#rrggbb"`, `[r, g, b]` 0–1, `{c, m, y, k}`, `{gray}`) → opaque RGB, `None` = transparent. pub(crate) fn background(v: &Value) -> std::result::Result, String> { @@ -276,12 +308,18 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()), _ => vec![boards(o.boards.one(n))?], }; + // JPEG Options → Image Map. + let map = match (format, o.image_map.as_deref()) { + (RasterFormat::Jpeg, Some(m)) => MapKind::from_id(m).ok_or_else(|| bad(C, format!("imageMap `{m}`: none, client or server")))?, + _ => MapKind::None, + }; let mut enc = Encoded::default(); let mut renderer = vectorcraft_render::Renderer::new(); for b in chosen { let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; + enc.maps.extend(imagemap::build(doc, region, scale, map, enc.files.len())); let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if format == RasterFormat::Png { diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index e16e97711..9ddd9e880 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -27,7 +27,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { "svg" => json!({ "text": String::from_utf8_lossy(bytes) }), _ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; - let mut out = merge(data(main.first().map_or(&[][..], |m| m.1)), json!({ "warnings": enc.warnings })); + let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings })); if main.len() > 1 { out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect(); } diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs new file mode 100644 index 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..2e8542647 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -16,6 +16,7 @@ mod batch; mod encode; mod export; +mod imagemap; mod load; pub mod pdf; mod pdfimport; @@ -63,7 +64,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -181,7 +182,8 @@ const RANGE: FormatOption = FormatOption { description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)", }; const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" }; -const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" }; +const QUALITY: FormatOption = + FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" }; const USE_ARTBOARDS: FormatOption = FormatOption { name: "useArtboards", ty: "boolean", @@ -208,6 +210,31 @@ const ANTI_ALIAS: FormatOption = FormatOption { }; const INTERLACED: FormatOption = FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" }; +const COLOR_MODEL: FormatOption = FormatOption { + name: "colorModel", + ty: "string", + default: "\"rgb\"", + description: "rgb, cmyk (ink amounts in the working CMYK space: CMYK colours keep their inks) or gray", +}; +const METHOD: FormatOption = FormatOption { + name: "method", + ty: "string", + default: "\"baseline\"", + description: "baseline (standard), optimized (smaller Huffman tables) or progressive (builds up in scans)", +}; +const SCANS: FormatOption = FormatOption { name: "scans", ty: "integer", default: "3", description: "progressive scans, 3–5" }; +const EMBED_ICC: FormatOption = FormatOption { + name: "embedIcc", + ty: "boolean", + default: "true", + description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve", +}; +const IMAGE_MAP: FormatOption = FormatOption { + name: "imageMap", + ty: "string", + default: "\"none\"", + description: "none, client (an HTML page with , .html) or server (an NCSA .map): the areas of objects with a URL and an Image Map shape (attributes.set)", +}; /// A format `document.open` reads but nothing writes yet. const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format { @@ -258,7 +285,22 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY], + options: &[ + ARTBOARD, + ARTBOARDS, + RANGE, + USE_ARTBOARDS, + PPI, + SCALE, + BACKGROUND, + ANTI_ALIAS, + QUALITY, + COLOR_MODEL, + METHOD, + SCANS, + EMBED_ICC, + IMAGE_MAP, + ], }, reader("gif", "GIF", &["gif"], "image/gif", true), Format { @@ -459,7 +501,7 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document, enc: &Encoded, extra: Value) -> Result { let files = enc.named(doc, path.unwrap_or(name)); let (main, linked) = files.split_at(enc.files.len()); - let row = |(name, bytes): &(String, &[u8])| match path { + let row = |(name, bytes): &(String, std::borrow::Cow<[u8]>)| match path { Some(_) => json!(name), None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; @@ -528,3 +570,6 @@ mod tests_raster; #[cfg(test)] mod tests_exportas; + +#[cfg(test)] +mod tests_jpeg; diff --git a/crates/engine/src/cmd/fileio/tests_jpeg.rs b/crates/engine/src/cmd/fileio/tests_jpeg.rs new file mode 100644 index 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(" &'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..0d90ff811 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -1,7 +1,8 @@ //! Raster export: render a document region with [`RasterExportOptions`] (resolution, background, //! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG -//! (resolution in the JFIF header) or lossless WebP. +//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile) or lossless WebP. +pub mod jpeg; pub mod png; use vectorcraft_doc::{Document, Node, NodeKind}; @@ -27,13 +28,15 @@ pub struct RasterExportOptions { pub anti_alias: AntiAlias, /// PNG: Adam7 interlacing. pub interlaced: bool, - /// JPEG quality 1–100. + /// JPEG quality 0–100. pub quality: u8, + /// JPEG colour model, coding and profile. + pub jpeg: jpeg::JpegOptions, } impl Default for RasterExportOptions { fn default() -> Self { - Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90 } + Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90, jpeg: jpeg::JpegOptions::default() } } } @@ -55,13 +58,20 @@ impl RasterExportOptions { } } - /// Encode a rendered image as `format`. + /// Encode a rendered image as `format`. A CMYK JPEG separates the screen colours; + /// [`Renderer::export_region`] draws the inks instead. pub fn encode(&self, img: &Rendered, format: RasterFormat) -> Result, String> { match format { RasterFormat::Png => { png::encode(&img.to_straight(), img.width, img.height, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }) } - RasterFormat::Jpeg => jpeg(img, self.quality, Some(self.ppi)), + RasterFormat::Jpeg => { + let px = match self.jpeg.color_model { + jpeg::ColorModel::Cmyk => jpeg::separated(img), + model => jpeg::screen_pixels(img, model == jpeg::ColorModel::Gray), + }; + jpeg::encode(&px, img.width, img.height, self.quality, Some(self.ppi), &self.jpeg) + } RasterFormat::WebP => webp(img), } } @@ -69,35 +79,24 @@ impl RasterExportOptions { impl Renderer { /// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`. - /// Callers check the size first ([`crate::raster_size`]). + /// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG is drawn as ink amounts + /// ([`Renderer::render_region_inks`]). pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result, String> { + if format == RasterFormat::Jpeg && opts.jpeg.color_model == jpeg::ColorModel::Cmyk { + let (w, h) = crate::region_pixels(region, opts.scale()); + let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format)); + return jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg); + } let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format)); opts.encode(&img, format) } } -/// JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the resolution -/// `ppi` (if any) in the JFIF header. +/// Baseline RGB JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the +/// resolution `ppi` (if any) in the JFIF header and no colour profile. pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option) -> Result, String> { - let rgb: Vec = img - .to_straight() - .as_chunks::<4>() - .0 - .iter() - .flat_map(|p| { - let a = p[3] as u32; - let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8; - [mix(p[0]), mix(p[1]), mix(p[2])] - }) - .collect(); - let mut buf = Vec::new(); - let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); - if let Some(ppi) = ppi { - enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(ppi.round().clamp(1.0, u16::MAX as f64) as u16)); - } - image::ImageEncoder::write_image(enc, &rgb, img.width, img.height, image::ExtendedColorType::Rgb8) - .map_err(|e| format!("JPEG encoding failed: {e}"))?; - Ok(buf) + let o = jpeg::JpegOptions { embed_icc: false, ..Default::default() }; + jpeg::encode(&jpeg::screen_pixels(img, false), img.width, img.height, quality, ppi, &o) } /// Lossless WebP. @@ -131,3 +130,5 @@ fn drawn_bounds(n: &Node) -> Option { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_jpeg; diff --git a/crates/render/src/encode/tests_jpeg.rs b/crates/render/src/encode/tests_jpeg.rs new file mode 100644 index 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/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/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index bcb16b886..b6f73006e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -308,3 +308,5 @@ mod tests; mod tests_export; #[cfg(test)] mod tests_import_pdf; +#[cfg(test)] +mod tests_raster_formats; diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 51feb18a0..7e60f65a7 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,11 +1,13 @@ //! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), -//! JPEG Options (quality instead of interlacing; no transparency) and WebP Options. Fields are -//! `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the dialog's. +//! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image +//! map) and WebP Options. Fields are `document.export` params (format, path, artboard choice…); +//! `__`-prefixed ones are the dialog's. use serde::Deserialize; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::jpeg::{self, ColorModel, Method}; use super::{DialogSpec, form}; use crate::state::Dialog; @@ -22,6 +24,11 @@ const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "Hi const BACKGROUNDS: [&str; 3] = ["transparent", "white", "black"]; /// Their labels, then `Other` (a colour). const BACKGROUND_LABELS: [&str; 4] = ["Transparent", "White", "Black", "Other"]; +/// JPEG image map param values and their labels. +const IMAGE_MAPS: [&str; 3] = ["none", "client", "server"]; +const IMAGE_MAP_LABELS: [&str; 3] = ["None", "Client-side (.html)", "Server-side (.map)"]; +/// JPEG quality on the dialog's 0–10 scale: the band each step falls in. +const QUALITY_BANDS: [&str; 11] = ["Low", "Low", "Low", "Medium", "Medium", "Medium", "High", "High", "Maximum", "Maximum", "Maximum"]; /// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`). fn kind(f: &Format) -> String { @@ -43,10 +50,25 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { o.insert("background".into(), json!(if white { "white" } else { "transparent" })); o.insert("antiAlias".into(), json!(AntiAlias::default().id())); match f.id { - "png" => o.insert("interlaced".into(), json!(false)), - "jpg" => o.insert("quality".into(), json!(90)), - _ => None, - }; + "png" => { + o.insert("interlaced".into(), json!(false)); + } + "jpg" => { + // A CMYK document exports CMYK by default. + let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk); + let model = if cmyk { ColorModel::Cmyk } else { ColorModel::Rgb }; + let defaults = jpeg::JpegOptions::default(); + o.extend([ + ("quality".into(), json!(90)), + ("colorModel".into(), json!(model.id())), + ("method".into(), json!(defaults.method.id())), + ("scans".into(), json!(defaults.scans)), + ("embedIcc".into(), json!(defaults.embed_icc)), + ("imageMap".into(), json!(IMAGE_MAPS[0])), + ]); + } + _ => {} + } if let Some((w, h)) = size { o.insert("__size".into(), json!([w, h])); } @@ -131,14 +153,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "interlaced", "Interlaced"); ui.end_row(); } - "jpg" => { - label(ui, "Quality:"); - let mut q = d.f64("quality", 90.0).round() as u8; - if ui.add(egui::Slider::new(&mut q, 1..=100)).changed() { - d.fields.insert("quality".into(), json!(q)); - } - ui.end_row(); - } + "jpg" => jpeg_rows(ui, d, &label), _ => {} } @@ -154,6 +169,56 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The JPEG Options rows: colour model, quality (0–10, stored ×10), method and scans, profile and +/// image map. +fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) { + label(ui, "Color Model:"); + choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label)); + ui.end_row(); + + label(ui, "Quality:"); + let mut q = (d.f64("quality", 90.0) / 10.0).round().clamp(0.0, 10.0) as u8; + ui.horizontal(|ui| { + if ui.add(egui::Slider::new(&mut q, 0..=10)).changed() { + d.fields.insert("quality".into(), json!(u32::from(q) * 10)); + } + label(ui, QUALITY_BANDS[q as usize]); + }); + ui.end_row(); + + label(ui, "Method:"); + ui.horizontal(|ui| { + choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label)); + if Method::from_id(&d.str("method")) == Some(Method::Progressive) { + label(ui, "Scans:"); + let mut scans = d.f64("scans", 3.0).round().clamp(*jpeg::SCANS.start() as f64, *jpeg::SCANS.end() as f64) as u8; + if ui.add(egui::DragValue::new(&mut scans).range(jpeg::SCANS)).changed() { + d.fields.insert("scans".into(), json!(scans)); + } + } + }); + ui.end_row(); + + label(ui, "Image Map:"); + choice(ui, d, "imageMap", &IMAGE_MAPS, &IMAGE_MAP_LABELS); + ui.end_row(); + + ui.label(""); + form::check(ui, d, "embedIcc", "Embed ICC Profile"); + ui.end_row(); +} + +/// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown). +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) { + let cur = d.str(key); + let at = ids.iter().position(|v| v.eq_ignore_ascii_case(&cur)).unwrap_or(0); + if let Some(i) = widgets::dropdown(ui, ("ro", key), labels.get(at).copied().unwrap_or_default(), labels, 150.0) + && let Some(v) = ids.get(i) + { + d.fields.insert(key.into(), json!(v)); + } +} + /// Write the file(s) with the chosen options. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (format, path) = (d.str("format"), d.str("path")); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 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..389105de3 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -0,0 +1,44 @@ +//! The options of the raster formats beyond PNG, drawn headlessly: JPEG. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, set}; +use super::*; + +/// A JPEG's SOF marker and component count. +fn jpeg_frame(file: &[u8]) -> (u8, u8) { + let mut i = 2; + while !(0xC0..=0xC2).contains(&file[i + 1]) { + i += 2 + u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize; + } + (file[i + 1], file[i + 9]) +} + +#[test] +fn jpeg_options_pick_the_colour_model_method_and_profile() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "jpg"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("jpgOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("colorModel"), d.str("method"), d.f64("scans", 0.0), d.bool("embedIcc"), d.str("imageMap")), + ("rgb".into(), "baseline".into(), 3.0, true, "none".into()) + ); + frame(&mut app); + set(&mut app, "method", json!("progressive")); + set(&mut app, "scans", json!(4)); + set(&mut app, "colorModel", json!("cmyk")); + set(&mut app, "quality", json!(30)); + frame(&mut app); + confirm(&mut app).unwrap(); + let file = written.borrow()[0].1.clone(); + assert_eq!(jpeg_frame(&file), (0xC2, 4), "progressive CMYK"); + assert!(file.windows(12).any(|w| w == b"ICC_PROFILE\0")); + + // A CMYK document exports CMYK by default. + app.run("object.convertDocumentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("file.exportAs", json!({"format": "jpg"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ec669659c..5417527de 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,6 +26,7 @@ | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | +| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | From ac77b52ad3953fee0e00a81387d18145b8ca4581 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 10:54:46 +0700 Subject: [PATCH 2/3] M4.49: PNG-8 and GIF export with a palette quantizer and dithering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document.export {format: "png8" | "gif"} reduces the art to a palette of at most `colors` (2–256) entries: median cut over a 15-bit histogram refined by k-means (perceptual, selective or adaptive weighting), or the web-safe, black-and-white or grey palettes; art with that many colours or fewer keeps them exactly. Dithering is Floyd–Steinberg diffusion, an 8×8 ordered pattern or noise, with an amount. Pixels under half opacity become one transparent entry; partly transparent edges blend over the matte. PNG-8 is an indexed PNG (PLTE, tRNS, 1–8 bits per pixel, Adam7); GIF is a GIF89a (optionally interlaced). Both are in document.formats, Export As (PNG-8 / GIF Options dialogs) and Export for Screens. document.formats' writable list and the MCP export tool's format enum gain gif and png8 (their tests list them). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/engine/src/cmd/fileio/encode.rs | 29 +- crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/mod.rs | 58 ++- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_palette.rs | 72 +++ crates/mcp/src/tests_fileio.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/render/Cargo.toml | 1 + crates/render/src/encode/gif.rs | 33 ++ crates/render/src/encode/mod.rs | 31 +- crates/render/src/encode/png.rs | 111 ++++- crates/render/src/encode/quantize.rs | 447 ++++++++++++++++++ crates/render/src/encode/tests_palette.rs | 180 +++++++ .../ui-egui/src/dialogs/export_for_screens.rs | 2 +- crates/ui-egui/src/dialogs/mod.rs | 2 +- crates/ui-egui/src/dialogs/png_options.rs | 95 +++- .../src/dialogs/tests_raster_formats.rs | 37 +- docs/control-protocol.md | 1 + 20 files changed, 1063 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_palette.rs create mode 100644 crates/render/src/encode/gif.rs create mode 100644 crates/render/src/encode/quantize.rs create mode 100644 crates/render/src/encode/tests_palette.rs diff --git a/Cargo.lock b/Cargo.lock index 4fce31e5d..cf4ab07ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4244,6 +4244,7 @@ name = "vectorcraft-render" version = "0.1.1" dependencies = [ "flate2", + "gif", "image", "jpeg-encoder", "log", diff --git a/Cargo.toml b/Cargo.toml index db45e12a1..9f086091a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ 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/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 9ddf104e3..5ada012c9 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -10,6 +10,7 @@ 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::*; @@ -144,6 +145,12 @@ struct RasterOptions { scans: Option, embed_icc: Option, image_map: Option, + colors: Option, + reduction: Option, + dither: Option, + dither_amount: Option, + transparency: Option, + matte: Option, } impl RasterOptions { @@ -169,6 +176,22 @@ impl RasterOptions { 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]), + }, + }, }) } } @@ -291,7 +314,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { 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). @@ -301,6 +324,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. @@ -322,7 +347,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { 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 diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 9ddd9e880..7974df5be 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -101,7 +101,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). diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 2e8542647..79aab41cd 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -55,7 +55,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, …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 ), @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|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?; 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 ), @@ -82,7 +82,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 ), @@ -236,6 +236,52 @@ const IMAGE_MAP: FormatOption = FormatOption { 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)", +}; +/// 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 { Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] } @@ -302,7 +348,7 @@ pub const FORMATS: &[Format] = &[ 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", @@ -325,6 +371,7 @@ 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 }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -573,3 +620,6 @@ mod tests_exportas; #[cfg(test)] mod tests_jpeg; + +#[cfg(test)] +mod tests_palette; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 32cafb001..013179dc6 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"]); 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_palette.rs b/crates/engine/src/cmd/fileio/tests_palette.rs new file mode 100644 index 000000000..703ea41c3 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_palette.rs @@ -0,0 +1,72 @@ +//! PNG-8 and GIF: palette exports through document.export and Export for Screens. + +use serde_json::{Value, json}; + +use super::*; + +fn b64(v: &Value) -> 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/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index a0ecfa7f3..26f5297b8 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"])); 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..04daf3930 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) 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 dcd6dc03c..0c06f79b3 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -21,6 +21,7 @@ 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/mod.rs b/crates/render/src/encode/mod.rs index 0d90ff811..d886e76e6 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -1,9 +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 -//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile) 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; @@ -16,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. @@ -26,17 +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 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, jpeg: jpeg::JpegOptions::default() } + Self { + ppi: 72.0, + background: None, + anti_alias: AntiAlias::Art, + interlaced: false, + quality: 90, + jpeg: jpeg::JpegOptions::default(), + palette: quantize::PaletteOptions::default(), + } } } @@ -73,6 +89,13 @@ impl RasterExportOptions { 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 }), + } + } } } } @@ -132,3 +155,5 @@ fn drawn_bounds(n: &Node) -> Option { 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_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/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 b6f73006e..50d224d21 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -192,7 +192,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, diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 7e60f65a7..0b28e0ad7 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,13 +1,15 @@ //! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), //! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image -//! map) and WebP Options. Fields are `document.export` params (format, path, artboard choice…); -//! `__`-prefixed ones are the dialog's. +//! 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; @@ -27,10 +29,16 @@ 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) } @@ -67,6 +75,18 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { ("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 { @@ -131,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(); @@ -154,6 +170,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); } "jpg" => jpeg_rows(ui, d, &label), + "gif" | "png8" => palette_rows(ui, d, &label), _ => {} } @@ -208,6 +225,68 @@ fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &s 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). +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). fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) { let cur = d.str(key); diff --git a/crates/ui-egui/src/dialogs/tests_raster_formats.rs b/crates/ui-egui/src/dialogs/tests_raster_formats.rs index 389105de3..e4a73adc4 100644 --- a/crates/ui-egui/src/dialogs/tests_raster_formats.rs +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -1,4 +1,4 @@ -//! The options of the raster formats beyond PNG, drawn headlessly: JPEG. +//! The options of the raster formats beyond PNG, drawn headlessly: JPEG, PNG-8 and GIF. use serde_json::json; @@ -42,3 +42,38 @@ fn jpeg_options_pick_the_colour_model_method_and_profile() { 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"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5417527de..ac7184e50 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -27,6 +27,7 @@ | `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 | | `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 | From da18cf4234453bc66c5e9f5200f62313515ac055 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:10:22 +0700 Subject: [PATCH 3/3] M4.50: WebP options, Export As Text and Save for Office Documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebP takes lossless and quality; there is no permissive pure-Rust lossy encoder yet, so lossless: false writes lossless with a warning (the WebP Options dialog says so). - document.export {format: "txt", encoding: utf8|utf16, lineEndings: lf|crlf, selectionOnly}: the stories in stacking order (back to front), a thread once, hidden objects and template layers left out; UTF-16 is little-endian with a byte order mark. Export As → Text opens Text Export Options. - File → Save for Office Documents… (document.exportForOffice {ppi, transparent, artboard, path}): the artboard as a PNG at 72/150/300 ppi on white or transparent, through a small dialog in the app. document.formats' writable list and the MCP export tool's enum gain txt. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 5 + crates/engine/src/cmd/fileio/export.rs | 70 ++++++++++---- crates/engine/src/cmd/fileio/mod.rs | 28 +++++- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_text.rs | 87 +++++++++++++++++ crates/engine/src/cmd/fileio/text.rs | 96 +++++++++++++++++++ crates/mcp/src/tests_fileio.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/src/dialogs/export_as.rs | 10 +- crates/ui-egui/src/dialogs/mod.rs | 4 + crates/ui-egui/src/dialogs/office_export.rs | 64 +++++++++++++ crates/ui-egui/src/dialogs/png_options.rs | 13 ++- .../src/dialogs/tests_raster_formats.rs | 58 ++++++++++- crates/ui-egui/src/dialogs/text_export.rs | 52 ++++++++++ crates/ui-egui/src/menus.rs | 4 + docs/control-protocol.md | 2 + docs/mcp.md | 2 +- 17 files changed, 470 insertions(+), 31 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_text.rs create mode 100644 crates/engine/src/cmd/fileio/text.rs create mode 100644 crates/ui-egui/src/dialogs/office_export.rs create mode 100644 crates/ui-egui/src/dialogs/text_export.rs diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 5ada012c9..4848b37d6 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -151,6 +151,7 @@ struct RasterOptions { dither_amount: Option, transparency: Option, matte: Option, + lossless: Option, } impl RasterOptions { @@ -309,6 +310,7 @@ 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)?; @@ -357,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 7974df5be..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()); @@ -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()] })) } @@ -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/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 79aab41cd..1ec7fcbf8 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -24,6 +24,7 @@ pub mod pngtext; pub mod ppi; mod save; mod svg; +mod text; use serde_json::{Value, json}; @@ -55,7 +56,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8, …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 ), @@ -64,7 +65,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|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?; 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", + "{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 ), @@ -105,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()) @@ -263,6 +273,14 @@ const MATTE: FormatOption = FormatOption { 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, @@ -357,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), @@ -372,6 +390,7 @@ pub const FORMATS: &[Format] = &[ 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). @@ -623,3 +642,6 @@ 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 013179dc6..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", "gif", "webp", "template", "png8"]); + 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_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 26f5297b8..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", "gif", "webp", "template", "png8"])); + 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 04daf3930..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/gif/png8 (one rendered artboard; png8 is an indexed .png) 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/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/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 50d224d21..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; @@ -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). 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 0b28e0ad7..c457b98b7 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -19,9 +19,9 @@ 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). @@ -171,6 +171,11 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } "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(); + } _ => {} } @@ -279,7 +284,7 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, } /// A colour button bound to `d.fields[key]` (`"#rrggbb"`; grey when unset). -fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { +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() { @@ -288,7 +293,7 @@ fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { } /// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown). -fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) { +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) diff --git a/crates/ui-egui/src/dialogs/tests_raster_formats.rs b/crates/ui-egui/src/dialogs/tests_raster_formats.rs index e4a73adc4..dcef576a2 100644 --- a/crates/ui-egui/src/dialogs/tests_raster_formats.rs +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -1,4 +1,5 @@ -//! The options of the raster formats beyond PNG, drawn headlessly: JPEG, PNG-8 and GIF. +//! 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; @@ -77,3 +78,58 @@ fn palette_options_reduce_the_colours() { 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 ac7184e50..f71ea1cbe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,6 +28,8 @@ | `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. |