From c6450f6f706727242d0d2c81cc6bd7fbf890401f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:20:37 +0700 Subject: [PATCH] M4.90: Save for Web (Legacy) previews and saves optimised GIF, JPEG, PNG-8 and PNG-24 images, slices and HTML File > Export > Save for Web (Legacy) (Ctrl/Cmd+Alt+Shift+S) opens a dialog with Original, Optimized, 2-Up and 4-Up views showing each file's size and download time, and settings for colour reduction, colours, dither, transparency and matte, interlacing, web snap, lossy GIF, JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size and anti-aliasing, Clip to Artboard, sRGB, metadata, slices (all or selected), HTML output and presets. Preview in Browser opens the HTML page. Engine: document.exportForWeb, document.exportForWeb.preview (its byte count is the size of the file Save writes), webExport.settings and webExport.presets.list/save/delete. Saved presets and the remembered settings are stored in Prefs. Render: quantize_locked, and encode::web for web snap, colour table edits, lossy GIF and GIF/JPEG comments. PNG files get an sRGB chunk. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/imagemap.rs | 4 +- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/slices.rs | 2 +- crates/engine/src/cmd/webexport.rs | 1077 +++++++++++++++++ crates/engine/src/lib.rs | 11 + crates/engine/src/tests_webexport.rs | 424 +++++++ crates/render/src/encode/mod.rs | 6 +- crates/render/src/encode/png.rs | 64 +- crates/render/src/encode/quantize.rs | 29 +- crates/render/src/encode/tests_web.rs | 109 ++ crates/render/src/encode/web.rs | 215 ++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/dialogs/mod.rs | 5 + crates/ui-egui/src/dialogs/save_for_web.rs | 832 +++++++++++++ .../ui-egui/src/dialogs/tests_save_for_web.rs | 151 +++ crates/ui-egui/src/io.rs | 2 +- crates/ui-egui/src/menus.rs | 19 +- docs/control-protocol.md | 9 + docs/mcp.md | 30 + 20 files changed, 2959 insertions(+), 36 deletions(-) create mode 100644 crates/engine/src/cmd/webexport.rs create mode 100644 crates/engine/src/tests_webexport.rs create mode 100644 crates/render/src/encode/tests_web.rs create mode 100644 crates/render/src/encode/web.rs create mode 100644 crates/ui-egui/src/dialogs/save_for_web.rs create mode 100644 crates/ui-egui/src/dialogs/tests_save_for_web.rs diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs index ef5e6863..6974fe8d 100644 --- a/crates/engine/src/cmd/fileio/imagemap.rs +++ b/crates/engine/src/cmd/fileio/imagemap.rs @@ -125,8 +125,8 @@ fn polygon(path: &kurbo::BezPath, tolerance: f64) -> Option> { (pts.len() >= 3).then_some(pts) } -/// Escape text for an HTML attribute. -fn attr(s: &str) -> String { +/// Escape text for an HTML attribute (or element text). +pub(crate) 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("&"), diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 06ef828c..95f779cf 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -25,7 +25,7 @@ pub mod dxfimport; mod encode; pub mod eps; mod export; -mod imagemap; +pub(crate) mod imagemap; mod load; mod metafile; mod native; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9004c3a4..45378e66 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -64,6 +64,7 @@ pub(crate) mod typecmd; mod typemenu; pub(crate) mod views; pub mod wand; +pub mod webexport; mod xform; use serde::Serialize; @@ -233,6 +234,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(print::specs()); v.extend(recovery::specs()); v.extend(fileio::dxfimport::specs()); + v.extend(webexport::specs()); v }) } diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 189b0b5e..b5b57af2 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -209,7 +209,7 @@ fn selected(s: &Session) -> Result> { Ok(selected_slices(&st.doc, &st.selection)) } -fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec { +pub(crate) fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec { let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some())); dedup(sel.slices.iter().chain(objects).copied().collect()) } diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs new file mode 100644 index 00000000..52ed5c67 --- /dev/null +++ b/crates/engine/src/cmd/webexport.rs @@ -0,0 +1,1077 @@ +//! File → Export → Save for Web (Legacy): an optimised web image — GIF, JPEG, PNG-8 or PNG-24 — +//! of an artboard or of the art, or one image per slice with an HTML page placing them, plus the +//! named settings the dialog and `webExport.presets.*` keep ([`WebSettings`], [`WebPreset`]). +//! +//! The pipeline: [`render`] draws the region once (straight RGBA at the output size); [`optimize`] +//! turns pixels into a file with the settings (palette reduction and the colour table's edits, web +//! snap, lossy, JPEG quality, metadata); [`decode`] reads a file back for the dialog's preview, so +//! what the preview shows and the size it reports are what Save writes. Slices are cut from the one +//! rendering, each optimised on its own. + +use std::collections::HashSet; + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Value, json}; +use vectorcraft_doc::slices::SliceArea; +use vectorcraft_doc::{CellAlign, CellVAlign, Document, NodeId, NodeKind, SliceKind}; +use vectorcraft_geom::Rect; +use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::quantize::{self, Dither, Indexed, PaletteOptions, Reduction}; +use vectorcraft_render::encode::web::{self, ColorSort}; +use vectorcraft_render::encode::{gif, jpeg, png}; + +use super::*; + +const C: &str = "document.exportForWeb"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "document.exportForWeb", + "Save for Web", + [], + None, + "{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, gif/png8: reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), jpg: quality?: 60 (0–100), progressive?: false, optimized?: true, embedProfile?: false (sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/.), output?: images (default)|html (an HTML page placing the image or slices, .html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself", + has_doc, + export + ), + cmd!( + query "document.exportForWeb.preview", + "Save for Web Preview", + [], + None, + "{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, dataBase64?}", + has_doc, + preview + ), + cmd!( + query "webExport.settings", + "Save for Web Settings", + [], + None, + "{reset?: false, preset?, …the settings of document.exportForWeb} remember settings for the Save for Web dialog (it opens on them): the keys given change the remembered ones, a preset replaces them, reset goes back to the defaults; {} reads them → {settings}", + always, + settings + ), + cmd!( + query "webExport.presets.list", + "Save for Web Presets", + [], + None, + "{} → {presets: [{name, builtIn, settings}]} the built-in presets, then the saved ones; any name works as `preset` in document.exportForWeb", + always, + presets_list + ), + cmd!( + query "webExport.presets.save", + "Save Save for Web Preset", + [], + None, + "{name, newName?: rename it, preset?: start from this preset (default: the saved preset `name`, else the defaults), settings?: {…}, …the settings of document.exportForWeb} create or change a saved preset (built-in ones can't change) → {name, settings, created}", + always, + presets_save + ), + cmd!( + query "webExport.presets.delete", + "Delete Save for Web Preset", + [], + None, + "{name} delete a saved preset (built-in ones stay) → {deleted: name}", + always, + presets_delete + ), + ] +} + +// ---------- settings ---------- + +/// Enums of the render crate written in settings by id. +trait Keyed: Sized + Copy { + fn key(self) -> &'static str; + fn parse(s: &str) -> Option; + const KEYS: &'static str; +} + +impl Keyed for Reduction { + fn key(self) -> &'static str { + self.id() + } + fn parse(s: &str) -> Option { + Self::from_id(s) + } + const KEYS: &'static str = "perceptual, selective, adaptive, web, blackWhite or gray"; +} + +impl Keyed for Dither { + fn key(self) -> &'static str { + self.id() + } + fn parse(s: &str) -> Option { + Self::from_id(s) + } + const KEYS: &'static str = "none, diffusion, pattern or noise"; +} + +impl Keyed for AntiAlias { + fn key(self) -> &'static str { + self.id() + } + fn parse(s: &str) -> Option { + Self::from_id(s) + } + const KEYS: &'static str = "none, art or type"; +} + +impl Keyed for ColorSort { + fn key(self) -> &'static str { + self.id() + } + fn parse(s: &str) -> Option { + Self::from_id(s) + } + const KEYS: &'static str = "none, hue, luminance or popularity"; +} + +fn ser_key(v: &T, s: S) -> std::result::Result { + s.serialize_str(v.key()) +} + +fn de_key<'de, T: Keyed, D: Deserializer<'de>>(d: D) -> std::result::Result { + let s = String::deserialize(d)?; + T::parse(&s).ok_or_else(|| D::Error::custom(format!("`{s}`: {}", T::KEYS))) +} + +/// The file format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WebFormat { + #[default] + Gif, + #[serde(alias = "jpeg")] + Jpg, + Png8, + Png24, +} + +impl WebFormat { + pub const ALL: [WebFormat; 4] = [WebFormat::Gif, WebFormat::Jpg, WebFormat::Png8, WebFormat::Png24]; + + pub fn id(self) -> &'static str { + match self { + WebFormat::Gif => "gif", + WebFormat::Jpg => "jpg", + WebFormat::Png8 => "png8", + WebFormat::Png24 => "png24", + } + } + + pub fn label(self) -> &'static str { + match self { + WebFormat::Gif => "GIF", + WebFormat::Jpg => "JPEG", + WebFormat::Png8 => "PNG-8", + WebFormat::Png24 => "PNG-24", + } + } + + pub fn ext(self) -> &'static str { + match self { + WebFormat::Gif => "gif", + WebFormat::Jpg => "jpg", + WebFormat::Png8 | WebFormat::Png24 => "png", + } + } + + /// A palette format (colour reduction, colour table). + pub fn palette(self) -> bool { + matches!(self, WebFormat::Gif | WebFormat::Png8) + } +} + +/// Which File Info goes into the files. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WebMetadata { + None, + #[default] + Copyright, + /// The copyright and the author. + Contact, + All, +} + +impl WebMetadata { + pub const ALL: [WebMetadata; 4] = [WebMetadata::None, WebMetadata::Copyright, WebMetadata::Contact, WebMetadata::All]; + + pub fn key(self) -> &'static str { + match self { + WebMetadata::None => "none", + WebMetadata::Copyright => "copyright", + WebMetadata::Contact => "contact", + WebMetadata::All => "all", + } + } + + pub fn label(self) -> &'static str { + match self { + WebMetadata::None => "None", + WebMetadata::Copyright => "Copyright", + WebMetadata::Contact => "Copyright and Contact Info", + WebMetadata::All => "All", + } + } + + /// Whether File Info entry `keyword` (as [`vectorcraft_doc::Metadata::png_text`] names it) goes in. + fn keeps(self, keyword: &str) -> bool { + match self { + WebMetadata::None => false, + WebMetadata::Copyright => keyword.starts_with("Copyright"), + WebMetadata::Contact => keyword.starts_with("Copyright") || keyword == "Author", + WebMetadata::All => true, + } + } +} + +/// Which slices get images. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SliceScope { + #[default] + All, + Selected, + /// One image, slices or not. + None, +} + +impl SliceScope { + pub const ALL: [SliceScope; 3] = [SliceScope::All, SliceScope::Selected, SliceScope::None]; + + pub fn key(self) -> &'static str { + match self { + SliceScope::All => "all", + SliceScope::Selected => "selected", + SliceScope::None => "none", + } + } + + pub fn label(self) -> &'static str { + match self { + SliceScope::All => "All Slices", + SliceScope::Selected => "Selected Slices", + SliceScope::None => "No Slices", + } + } +} + +/// What Save writes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WebOutput { + #[default] + Images, + /// An HTML page placing the images too. + Html, +} + +impl WebOutput { + pub const ALL: [WebOutput; 2] = [WebOutput::Images, WebOutput::Html]; + + pub fn key(self) -> &'static str { + match self { + WebOutput::Images => "images", + WebOutput::Html => "html", + } + } + + pub fn label(self) -> &'static str { + match self { + WebOutput::Images => "Images Only", + WebOutput::Html => "HTML and Images", + } + } +} + +/// The colour table's edits of a palette format, by the colours the reduction makes (the +/// preview's `colors[].source`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ColorTable { + /// Kept when the colours are reduced further. + pub locked: Vec, + /// Become transparent. + pub transparent: Vec, + /// Become their nearest web-safe colour. + pub web_shift: Vec, + #[serde(serialize_with = "ser_key", deserialize_with = "de_key")] + pub sort: ColorSort, +} + +/// The most colours a colour table list holds. +const MAX_TABLE: usize = 256; + +impl ColorTable { + fn colors(list: &[String], what: &str) -> std::result::Result, String> { + if list.len() > MAX_TABLE { + return Err(format!("colorTable.{what}: at most {MAX_TABLE} colours")); + } + list.iter() + .map(|h| { + vectorcraft_color::Color::from_hex(h) + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }) + .ok_or_else(|| format!("colorTable.{what}: `{h}` is not a colour such as \"#ff8800\"")) + }) + .collect() + } +} + +/// Save for Web's settings: the params of `document.exportForWeb`, a preset's content and what the +/// dialog opens on. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct WebSettings { + pub format: WebFormat, + #[serde(serialize_with = "ser_key", deserialize_with = "de_key")] + pub reduction: Reduction, + /// Most palette entries, 2–256. + pub colors: u16, + #[serde(serialize_with = "ser_key", deserialize_with = "de_key")] + pub dither: Dither, + /// 0–100. + pub dither_amount: u8, + /// Keep transparent pixels transparent (GIF, PNG-8, PNG-24). + pub transparency: bool, + /// What partly transparent edges blend into (JPEG and opaque PNG-24: the background): `white`, + /// `black`, `none` or `#rrggbb`. + pub matte: String, + pub interlaced: bool, + /// 0–100 %: palette colours that close to a web-safe colour become it. + pub web_snap: u8, + /// 0–100: lossy GIF compression. + pub lossy: u8, + pub color_table: ColorTable, + /// JPEG quality 0–100. + pub quality: u8, + pub progressive: bool, + /// Optimised Huffman tables (smaller JPEG files). + pub optimized: bool, + /// JPEG: embed the sRGB profile. + pub embed_profile: bool, + /// The image width in pixels (the height follows). + pub width: Option, + /// The image height in pixels (the width follows). + pub height: Option, + /// The image size in percent of the art's size in points. + pub percent: Option, + #[serde(serialize_with = "ser_key", deserialize_with = "de_key")] + pub anti_alias: AntiAlias, + /// The artboard (else the visible art and the slices). + pub clip_to_artboard: bool, + /// 0-based (default the first). + pub artboard: Option, + /// PNG files say their colours are sRGB (what the renderer draws). + pub convert_to_srgb: bool, + pub metadata: WebMetadata, + pub slices: SliceScope, + pub output: WebOutput, +} + +impl Default for WebSettings { + fn default() -> Self { + Self { + format: WebFormat::Gif, + reduction: Reduction::Selective, + colors: 128, + dither: Dither::Diffusion, + dither_amount: 100, + transparency: true, + matte: "white".into(), + interlaced: false, + web_snap: 0, + lossy: 0, + color_table: ColorTable::default(), + quality: 60, + progressive: false, + optimized: true, + embed_profile: false, + width: None, + height: None, + percent: None, + anti_alias: AntiAlias::Art, + clip_to_artboard: true, + artboard: None, + convert_to_srgb: true, + metadata: WebMetadata::Copyright, + slices: SliceScope::All, + output: WebOutput::Images, + } + } +} + +impl WebSettings { + /// `self` with the settings keys of `p` (other keys are ignored: params carry `path`, `kbps`…). + pub fn merged(&self, p: &Value) -> std::result::Result { + let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?; + if let (Some(obj), Some(over)) = (v.as_object_mut(), p.as_object()) { + for (k, val) in over { + if let Some(slot) = obj.get_mut(k) { + *slot = val.clone(); + } + } + } + let s: WebSettings = serde_json::from_value(v).map_err(|e| format!("Save for Web settings: {e}"))?; + s.checked() + } + + /// Reject values no image can have. + fn checked(mut self) -> std::result::Result { + self.colors = self.colors.clamp(2, 256); + self.dither_amount = self.dither_amount.min(100); + self.web_snap = self.web_snap.min(100); + self.lossy = self.lossy.min(100); + self.quality = self.quality.min(100); + if let Some(p) = self.percent.filter(|p| !(p.is_finite() && *p > 0.0 && *p <= 6400.0)) { + return Err(format!("percent must be above 0 and at most 6400, not {p}")); + } + if self.width == Some(0) || self.height == Some(0) { + return Err("width and height are at least 1 pixel".into()); + } + self.matte_rgb()?; + for (list, what) in + [(&self.color_table.locked, "locked"), (&self.color_table.transparent, "transparent"), (&self.color_table.web_shift, "webShift")] + { + ColorTable::colors(list, what)?; + } + Ok(self) + } + + /// The matte as RGB (`None`: none). + pub fn matte_rgb(&self) -> std::result::Result, String> { + super::fileio::background(&json!(self.matte)).map_err(|e| format!("matte: {e}")) + } + + /// The JSON the commands return (and presets store). + pub fn to_json(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } +} + +/// A named set of settings: built in, or saved in the preferences ([`crate::Prefs::web_export_presets`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebPreset { + pub name: String, + #[serde(default)] + pub settings: WebSettings, +} + +/// The built-in presets (made here, named by what they do). +pub fn builtin_presets() -> Vec { + let d = WebSettings::default; + let preset = |name: &str, settings: WebSettings| WebPreset { name: name.into(), settings }; + vec![ + preset("GIF, 128 colours", d()), + preset("GIF, 32 colours, no dither", WebSettings { colors: 32, dither: Dither::None, ..d() }), + preset("GIF, web-safe colours", WebSettings { reduction: Reduction::Web, colors: 256, dither: Dither::None, ..d() }), + preset("JPEG, quality 80", WebSettings { format: WebFormat::Jpg, quality: 80, ..d() }), + preset("JPEG, quality 50", WebSettings { format: WebFormat::Jpg, quality: 50, ..d() }), + preset("JPEG, quality 25", WebSettings { format: WebFormat::Jpg, quality: 25, ..d() }), + preset("PNG-8, 64 colours", WebSettings { format: WebFormat::Png8, colors: 64, ..d() }), + preset("PNG-24", WebSettings { format: WebFormat::Png24, ..d() }), + ] +} + +fn saved_index(saved: &[WebPreset], name: &str) -> Option { + saved.iter().position(|p| p.name.eq_ignore_ascii_case(name.trim())) +} + +fn is_builtin(name: &str) -> bool { + builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) +} + +impl Session { + /// Every Save for Web preset: the built-in ones, then the saved ones. + pub fn web_presets(&self) -> Vec { + let mut v = builtin_presets(); + v.extend(self.prefs.web_export_presets.iter().cloned()); + v + } + + /// The settings `p` asks for: its `preset` (else `base`) with its settings keys. + pub fn web_settings_over(&self, base: &WebSettings, p: &Value) -> std::result::Result { + let start = match p.get("preset").and_then(Value::as_str) { + Some(name) => { + let key = name.trim(); + self.web_presets() + .into_iter() + .find(|q| q.name.eq_ignore_ascii_case(key)) + .ok_or_else(|| format!("unknown preset `{name}` (see webExport.presets.list)"))? + .settings + } + None => base.clone(), + }; + start.merged(p) + } + + /// The settings of a `document.exportForWeb` call: the defaults (or its preset) with its keys. + pub fn web_settings(&self, p: &Value) -> std::result::Result { + self.web_settings_over(&WebSettings::default(), p) + } + + /// What the Save for Web dialog opens on: the remembered settings, else the defaults. + pub fn last_web_settings(&self) -> WebSettings { + self.prefs.web_export_settings.clone().unwrap_or_default() + } +} + +// ---------- the pipeline ---------- + +/// The region an export covers and its pixels per point. +pub fn region(doc: &Document, s: &WebSettings) -> std::result::Result<(Rect, f64), String> { + let r = if s.clip_to_artboard { + let i = s.artboard.unwrap_or(0); + doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| format!("no artboard {i} (0-based; the document has {})", doc.artboards.len()))? + } else { + let slices = if s.slices == SliceScope::None { vec![] } else { doc.slice_layout() }; + slices + .iter() + .fold(vectorcraft_render::encode::art_bounds(doc), |acc, a| vectorcraft_geom::union_opt(acc, Some(a.rect))) + .ok_or("nothing to save: the document has no visible art")? + }; + let scale = match (s.width, s.height, s.percent) { + (Some(w), _, _) => f64::from(w) / r.width(), + (None, Some(h), _) => f64::from(h) / r.height(), + (None, None, Some(p)) => p / 100.0, + _ => 1.0, + }; + vectorcraft_render::raster_size(r, scale)?; + Ok((r, scale)) +} + +/// A rendering of the region an export covers, before optimisation. +#[derive(Clone, Debug)] +pub struct WebRender { + /// Straight RGBA, `width`×`height`. + pub rgba: Vec, + pub width: u32, + pub height: u32, + pub region: Rect, + /// Pixels per point. + pub scale: f64, +} + +/// Draw what `s` exports of `doc` (template layers left out; on white when the document's +/// background is white). +pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result { + let doc = doc.without_edit_modes(); + let (region, scale) = region(&doc, s)?; + let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 4]); + let opts = vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render_region_with(&doc, region, scale, &opts); + Ok(WebRender { rgba: img.to_straight(), width: img.width, height: img.height, region, scale }) +} + +/// One entry of a palette format's colour table. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TableColor { + /// As written. + pub color: [u8; 3], + /// As the reduction made it (what the colour table's lists name). + pub source: [u8; 3], + pub transparent: bool, + pub locked: bool, + pub web_shifted: bool, +} + +/// An optimised image file. +#[derive(Clone, Debug, Default)] +pub struct Optimized { + pub bytes: Vec, + pub width: u32, + pub height: u32, + /// The colour table (palette formats), in file order. + pub colors: Vec, + /// The colour table's colours that became transparent. + pub mapped: Vec<[u8; 3]>, + /// For [`preview`]: the same pixels in a file every decoder reads (a JPEG with optimised + /// Huffman tables decodes wrongly in the `image` crate's decoder; a baseline one of the same + /// quality has the same pixels). Empty when `bytes` serves. + display: Vec, +} + +impl Optimized { + /// The file the preview decodes ([`decode`]). + pub fn display(&self) -> &[u8] { + if self.display.is_empty() { &self.bytes } else { &self.display } + } +} + +/// `#rrggbb`. +pub fn hex([r, g, b]: [u8; 3]) -> String { + vectorcraft_color::Color::rgb8(r, g, b).to_hex() +} + +/// `rgba` (straight, `w`×`h`) flattened on `matte`. +fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator + '_ { + rgba.as_chunks::<4>().0.iter().map(move |p| { + let a = u32::from(p[3]); + [0, 1, 2].map(|i| ((u32::from(p[i]) * a + u32::from(matte[i]) * (255 - a) + 127) / 255) as u8) + }) +} + +/// The File Info text `s.metadata` lets into the files: `(keyword, text)`. +fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> { + doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() +} + +/// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata. +pub fn optimize(doc: &Document, rgba: &[u8], w: u32, h: u32, s: &WebSettings) -> std::result::Result { + encode(doc, rgba, w, h, s, false) +} + +/// [`optimize`] for the dialog's preview: also what [`Optimized::display`] decodes. +pub fn preview_image(doc: &Document, rgba: &[u8], w: u32, h: u32, s: &WebSettings) -> std::result::Result { + encode(doc, rgba, w, h, s, true) +} + +fn encode(doc: &Document, rgba: &[u8], w: u32, h: u32, s: &WebSettings, preview: bool) -> std::result::Result { + if rgba.len() as u64 != u64::from(w) * u64::from(h) * 4 || w == 0 || h == 0 { + return Err("Save for Web: the image is empty".into()); + } + let matte = s.matte_rgb()?; + let meta = metadata(doc, s); + let comment = || meta.iter().map(|(k, v)| format!("{k}: {v}")).collect::>().join("\n"); + let png_extras = |file: Vec| { + let file = if s.convert_to_srgb { png::with_srgb(file) } else { file }; + let text: Vec<(&str, std::borrow::Cow)> = meta.iter().map(|(k, v)| (*k, v.as_str().into())).collect(); + png::with_text(file, &text) + }; + let png_options = png::PngOptions { ppi: Some(72.0), interlaced: s.interlaced }; + let mut out = Optimized { width: w, height: h, ..Default::default() }; + out.bytes = match s.format { + WebFormat::Jpg => { + let rgb: Vec = flatten(rgba, matte.unwrap_or([255; 3])).flatten().collect(); + let method = match (s.progressive, s.optimized) { + (true, _) => jpeg::Method::Progressive, + (false, true) => jpeg::Method::Optimized, + (false, false) => jpeg::Method::Baseline, + }; + let o = jpeg::JpegOptions { color_model: jpeg::ColorModel::Rgb, method, scans: 3, embed_icc: s.embed_profile }; + if preview && method == jpeg::Method::Optimized { + let baseline = jpeg::JpegOptions { method: jpeg::Method::Baseline, embed_icc: false, ..o.clone() }; + out.display = jpeg::encode(&rgb, w, h, s.quality, None, &baseline)?; + } + web::jpeg_comment(jpeg::encode(&rgb, w, h, s.quality, Some(72.0), &o)?, &comment()) + } + WebFormat::Png24 => { + let file = match (s.transparency, matte.unwrap_or([255; 3])) { + (true, _) => png::encode(rgba, w, h, &png_options)?, + (false, m) => { + let opaque: Vec = flatten(rgba, m).flat_map(|[r, g, b]| [r, g, b, 255]).collect(); + png::encode(&opaque, w, h, &png_options)? + } + }; + png_extras(file) + } + WebFormat::Gif | WebFormat::Png8 => { + let PaletteImage { ix, colors, mapped } = palette_image(rgba, w, h, s, matte)?; + out.colors = colors; + out.mapped = mapped; + match s.format { + WebFormat::Gif => web::gif_comment(gif::encode(&ix, s.interlaced)?, &comment()), + _ => png_extras(png::encode_indexed(&ix, &png_options)?), + } + } + }; + Ok(out) +} + +/// A palette format's image with the colour table's edits. +struct PaletteImage { + ix: Indexed, + /// Its colour table. + colors: Vec, + /// The colours mapped to transparent. + mapped: Vec<[u8; 3]>, +} + +fn palette_image(rgba: &[u8], w: u32, h: u32, s: &WebSettings, matte: Option<[u8; 3]>) -> std::result::Result { + let table = &s.color_table; + let locked = ColorTable::colors(&table.locked, "locked")?; + let to_clear = ColorTable::colors(&table.transparent, "transparent")?; + let shift = ColorTable::colors(&table.web_shift, "webShift")?; + let o = PaletteOptions { + colors: s.colors, + reduction: s.reduction, + dither: s.dither, + dither_amount: s.dither_amount, + transparency: s.transparency, + matte, + }; + let mut ix = quantize::quantize_locked(rgba, w, h, &o, &locked); + // Map to transparent works on the colours the reduction made. + let before = ix.palette.clone(); + let clear = ix.transparent.map(usize::from); + let mapped: Vec<[u8; 3]> = before.iter().enumerate().filter(|(i, c)| Some(*i) != clear && to_clear.contains(c)).map(|(_, c)| *c).collect(); + let remap = web::to_transparent(&mut ix, |i| Some(i) != clear && before.get(i).is_some_and(|c| to_clear.contains(c))); + let mut sources = ix.palette.clone(); + for (old, new) in remap.iter().enumerate() { + if let (Some(n), Some(c)) = (new, before.get(old)) + && let Some(slot) = sources.get_mut(usize::from(*n)) + { + *slot = *c; + } + } + // Web snap, then the colours shifted by hand (locked colours stay as they are). + let clear = ix.transparent.map(usize::from); + for (i, c) in ix.palette.iter_mut().enumerate() { + let src = sources.get(i).copied().unwrap_or(*c); + if Some(i) == clear || locked.contains(&src) { + continue; + } + if shift.contains(&src) { + *c = quantize::web_safe(*c); + } else if let Some(snapped) = web::snap(*c, s.web_snap) { + *c = snapped; + } + } + if s.format == WebFormat::Gif { + web::lossy(&mut ix, rgba, s.lossy); + } + let order = web::sort(&mut ix, table.sort); + let sources: Vec<[u8; 3]> = order.iter().map(|&i| sources.get(i).copied().unwrap_or_default()).collect(); + let colors = ix + .palette + .iter() + .zip(&sources) + .enumerate() + .map(|(i, (c, src))| TableColor { + color: *c, + source: *src, + transparent: ix.transparent == u8::try_from(i).ok(), + locked: locked.contains(src), + web_shifted: shift.contains(src), + }) + .collect(); + Ok(PaletteImage { ix, colors, mapped }) +} + +/// An optimised image as straight RGBA → `(pixels, width, height)` (the dialog's preview). +pub fn decode(o: &Optimized) -> std::result::Result<(Vec, u32, u32), String> { + let img = image::load_from_memory(o.display()).map_err(|e| format!("the optimised image can't be read back: {e}"))?.to_rgba8(); + let (w, h) = img.dimensions(); + Ok((img.into_raw(), w, h)) +} + +/// Seconds to download `bytes` at `kbps` kilobits per second. +pub fn download_seconds(bytes: usize, kbps: f64) -> f64 { + if kbps > 0.0 { bytes as f64 * 8.0 / (kbps * 1000.0) } else { 0.0 } +} + +/// Connection speeds the dialog offers (kilobits per second). +pub const SPEEDS: [f64; 8] = [28.8, 56.6, 128.0, 256.0, 512.0, 1024.0, 2048.0, 8192.0]; + +// ---------- slices and the HTML page ---------- + +/// One slice as written: its area in image pixels and what its cell holds. +struct Cell<'a> { + area: &'a SliceArea, + /// Pixel rectangle `[x0, y0, x1, y1]` in the image. + px: [u32; 4], +} + +/// The slices of `doc` that get cells (`selected`: the selected slice ids for +/// [`SliceScope::Selected`]), in pixels of `r`. Empty when the document has none (or `s` says none). +fn cells<'a>(areas: &'a [SliceArea], selected: &[NodeId], s: &WebSettings, r: &WebRender) -> std::result::Result>, String> { + if s.slices == SliceScope::None { + return Ok(vec![]); + } + let edge = |v: f64, o: f64, max: u32| ((v - o) * r.scale).round().clamp(0.0, f64::from(max)) as u32; + let out: Vec = areas + .iter() + .filter(|a| s.slices == SliceScope::All || a.id.is_some_and(|id| selected.contains(&id))) + .map(|a| Cell { + area: a, + px: [ + edge(a.rect.x0, r.region.x0, r.width), + edge(a.rect.y0, r.region.y0, r.height), + edge(a.rect.x1, r.region.x0, r.width), + edge(a.rect.y1, r.region.y0, r.height), + ], + }) + .filter(|c| c.px[2] > c.px[0] && c.px[3] > c.px[1]) + .collect(); + if out.is_empty() && s.slices == SliceScope::Selected { + return Err("no selected slice in the image: select slices, or save all of them".into()); + } + Ok(out) +} + +/// The pixels of `px` (`[x0, y0, x1, y1]`) of `r`. +fn crop(r: &WebRender, [x0, y0, x1, y1]: [u32; 4]) -> Vec { + let stride = r.width as usize * 4; + (y0..y1) + .flat_map(|y| r.rgba.get(y as usize * stride + x0 as usize * 4..y as usize * stride + x1 as usize * 4).unwrap_or_default()) + .copied() + .collect() +} + +/// What an export writes, by name relative to its folder. +pub struct WebFile { + pub name: String, + pub bytes: Vec, +} + +/// The images folder of HTML and sliced output. +const IMAGES: &str = "images"; + +/// The files of an export of `doc` named after `stem`: one image (`.`), or one per +/// slice (`images/.`), and with HTML output the page (`.html`, its image in +/// `images/`). The page comes first. Returns the image size too. +pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem: &str) -> std::result::Result<(Vec, (u32, u32)), String> { + let r = render(doc, s)?; + let areas = doc.slice_layout(); + let cells = cells(&areas, selected, s, &r)?; + let ext = s.format.ext(); + let html = s.output == WebOutput::Html; + let mut images = vec![]; + // (cell, image file name) for the page. + let mut placed: Vec<(Option<&Cell>, Option)> = vec![]; + if cells.is_empty() { + let name = if html { format!("{IMAGES}/{stem}.{ext}") } else { format!("{stem}.{ext}") }; + images.push(WebFile { name: name.clone(), bytes: optimize(doc, &r.rgba, r.width, r.height, s)?.bytes }); + placed.push((None, Some(name))); + } else { + let mut taken = HashSet::new(); + for c in &cells { + let kind = c.area.id.and_then(|id| doc.slice_options(id)).map(|o| o.kind).unwrap_or_default(); + if kind != SliceKind::Image { + placed.push((Some(c), None)); + continue; + } + let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect(); + let base = unique_name(&base, |n| taken.contains(&n.to_lowercase())); + taken.insert(base.to_lowercase()); + let name = format!("{IMAGES}/{base}.{ext}"); + let [x0, y0, x1, y1] = c.px; + images.push(WebFile { name: name.clone(), bytes: optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, s)?.bytes }); + placed.push((Some(c), Some(name))); + } + } + let mut files = vec![]; + if html { + files.push(WebFile { name: format!("{stem}.html"), bytes: page(doc, stem, (r.width, r.height), &placed, s).into_bytes() }); + } + files.extend(images); + Ok((files, (r.width, r.height))) +} + +/// The HTML page placing the image or the slices (absolutely positioned, so overlapping slices +/// stack as laid out). +fn page(doc: &Document, stem: &str, (w, h): (u32, u32), placed: &[(Option<&Cell>, Option)], s: &WebSettings) -> String { + use super::fileio::imagemap::attr; + use std::fmt::Write as _; + let id = attr(stem); + let mut out = String::new(); + let _ = writeln!(out, "\n\n\n\n{id}"); + let _ = writeln!(out, "" + ); + let _ = writeln!(out, "\n\n
"); + let matte = s.matte_rgb().ok().flatten().map(hex).unwrap_or_default(); + for (cell, image) in placed { + let [x0, y0, x1, y1] = cell.map_or([0, 0, w, h], |c| c.px); + let (cw, ch) = (x1 - x0, y1 - y0); + let pos = format!("left: {x0}px; top: {y0}px; width: {cw}px; height: {ch}px;"); + let o = cell.and_then(|c| c.area.id).and_then(|i| doc.slice_options(i)).cloned().unwrap_or_default(); + match image { + Some(src) => { + let img = format!("\"{}\"", attr(src), attr(&o.alt)); + if o.url.is_empty() { + let _ = writeln!(out, "
{img}
"); + } else { + let target = if o.target.is_empty() { String::new() } else { format!(" target=\"{}\"", attr(&o.target)) }; + let title = if o.message.is_empty() { String::new() } else { format!(" title=\"{}\"", attr(&o.message)) }; + let _ = writeln!(out, "{img}", attr(&o.url)); + } + } + None => { + let bg = match o.background.as_str() { + "" => String::new(), + "matte" if !matte.is_empty() => format!(" background: {matte};"), + c if c.starts_with('#') => format!(" background: {};", attr(c)), + _ => String::new(), + }; + let justify = match o.h_align { + CellAlign::Center => "center", + CellAlign::Right => "flex-end", + _ => "flex-start", + }; + let align = match o.v_align { + CellVAlign::Middle => "center", + CellVAlign::Bottom => "flex-end", + CellVAlign::Baseline => "baseline", + _ => "flex-start", + }; + // No Image: the cell's text as it is (HTML allowed); HTML Text: the type's text. + let text = match o.kind { + SliceKind::HtmlText => cell + .and_then(|c| c.area.id) + .and_then(|i| doc.node(i)) + .and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.plain_text().split(['\n', '\r', '\u{2029}']).map(attr).collect::>().join("
")), + _ => None, + }) + .unwrap_or_default(), + _ => o.text.clone(), + }; + let _ = writeln!(out, "
{text}
"); + } + } + } + out.push_str("
\n\n\n"); + out +} + +// ---------- commands ---------- + +fn export(s: &mut Session, p: &Value) -> Result { + let settings = s.web_settings(p).map_err(|e| bad(C, e))?; + let st = s.doc()?; + let selected = super::slices::selected_slices(&st.doc, &st.selection); + let path = str_param(p, "path").filter(|p| !p.trim().is_empty()); + let stem = match path { + Some(p) => super::fileio::file_stem(p), + None => super::fileio::file_stem(&st.doc.title), + }; + let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem }; + let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?; + let total: usize = files.iter().map(|f| f.bytes.len()).sum(); + let Some(path) = path else { + let rows: Vec = files.iter().map(|f| json!({ "name": f.name, "dataBase64": vectorcraft_format::base64_encode(&f.bytes) })).collect(); + return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height })); + }; + // Siblings keep the path's own separators. + let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)]; + if files.iter().any(|f| f.name.contains('/')) { + super::fileio::create_dir(&format!("{dir}{IMAGES}"))?; + } + let mut written = vec![]; + for f in &files { + // A lone image goes where it was asked to. + let target = if files.len() == 1 && !f.name.contains('/') { path.to_string() } else { format!("{dir}{}", f.name) }; + super::fileio::write_file(&target, &f.bytes)?; + written.push(target); + } + Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height })) +} + +fn preview(s: &mut Session, p: &Value) -> Result { + const P: &str = "document.exportForWeb.preview"; + let settings = s.web_settings(p).map_err(|e| bad(P, e))?; + let kbps = f64_or(p, "kbps", 56.6); + if !(kbps.is_finite() && kbps > 0.0) { + return Err(bad(P, format!("kbps must be a positive number, not {kbps}"))); + } + let st = s.doc()?; + let doc = &st.doc; + let r = render(doc, &settings).map_err(|e| bad(P, e))?; + let areas = doc.slice_layout(); + let selected = super::slices::selected_slices(doc, &st.selection); + let cells = cells(&areas, &selected, &settings, &r).map_err(|e| bad(P, e))?; + let images = cells.iter().filter(|c| c.area.id.and_then(|id| doc.slice_options(id)).is_none_or(|o| o.kind == SliceKind::Image)).count().max(1); + let o = match p.get("slice").and_then(Value::as_u64) { + Some(n) => { + let c = cells.iter().find(|c| c.area.number as u64 == n).ok_or_else(|| bad(P, format!("no slice {n} in the image (see slice.list)")))?; + let [x0, y0, x1, y1] = c.px; + optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, &settings) + } + None => optimize(doc, &r.rgba, r.width, r.height, &settings), + } + .map_err(|e| bad(P, e))?; + let mut out = json!({ + "format": settings.format.id(), "width": o.width, "height": o.height, "bytes": o.bytes.len(), + "seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images, + }); + if settings.format.palette() { + out["colors"] = o + .colors + .iter() + .map(|c| { + json!({ + "color": hex(c.color), "source": hex(c.source), "transparent": c.transparent, "locked": c.locked, + "webShifted": c.web_shifted, "webSafe": quantize::web_safe(c.color) == c.color, + }) + }) + .collect(); + out["mappedToTransparent"] = o.mapped.iter().map(|c| json!(hex(*c))).collect(); + } + if bool_or(p, "image", false) { + out["dataBase64"] = json!(vectorcraft_format::base64_encode(&o.bytes)); + } + Ok(out) +} + +fn settings(s: &mut Session, p: &Value) -> Result { + const S: &str = "webExport.settings"; + let base = if bool_or(p, "reset", false) { WebSettings::default() } else { s.last_web_settings() }; + let next = s.web_settings_over(&base, p).map_err(|e| bad(S, e))?; + if p.as_object().is_some_and(|o| !o.is_empty()) { + s.prefs.web_export_settings = Some(next.clone()); + } + Ok(json!({ "settings": next.to_json() })) +} + +fn presets_list(s: &mut Session, _: &Value) -> Result { + let rows = builtin_presets() + .into_iter() + .map(|p| (p, true)) + .chain(s.prefs.web_export_presets.iter().cloned().map(|p| (p, false))) + .map(|(p, builtin)| json!({ "name": p.name, "builtIn": builtin, "settings": p.settings.to_json() })); + Ok(json!({ "presets": rows.collect::>() })) +} + +fn presets_save(s: &mut Session, p: &Value) -> Result { + const S: &str = "webExport.presets.save"; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(S, "give the preset a `name`"))?.to_string(); + if is_builtin(&name) { + return Err(bad(S, format!("`{name}` is a built-in preset and can't change: save it under another name"))); + } + let at = saved_index(&s.prefs.web_export_presets, &name); + let base = at.and_then(|i| s.prefs.web_export_presets.get(i)).map(|q| q.settings.clone()).unwrap_or_default(); + // Settings at the top level, or in `settings`. + let mut keys = p.as_object().cloned().unwrap_or_default(); + if let Some(Value::Object(inner)) = keys.remove("settings") { + keys.extend(inner); + } + let settings = s.web_settings_over(&base, &Value::Object(keys)).map_err(|e| bad(S, e))?; + let name = match str_param(p, "newName").map(str::trim) { + Some("") => return Err(bad(S, "`newName` is empty")), + Some(n) if is_builtin(n) || saved_index(&s.prefs.web_export_presets, n).is_some_and(|i| Some(i) != at) => { + return Err(bad(S, format!("a preset named `{n}` exists"))); + } + Some(n) => n.to_string(), + None => at.and_then(|i| s.prefs.web_export_presets.get(i)).map_or(name, |q| q.name.clone()), + }; + let preset = WebPreset { name: name.clone(), settings: settings.clone() }; + match at.and_then(|i| s.prefs.web_export_presets.get_mut(i)) { + Some(slot) => *slot = preset, + None => s.prefs.web_export_presets.push(preset), + } + Ok(json!({ "name": name, "settings": settings.to_json(), "created": at.is_none() })) +} + +fn presets_delete(s: &mut Session, p: &Value) -> Result { + const S: &str = "webExport.presets.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(S, "missing `name`"))?; + if is_builtin(name) { + return Err(bad(S, format!("`{name}` is a built-in preset and stays"))); + } + let i = saved_index(&s.prefs.web_export_presets, name).ok_or_else(|| bad(S, format!("no saved preset named `{name}`")))?; + Ok(json!({ "deleted": s.prefs.web_export_presets.remove(i).name })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fd981a49..29103d00 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -458,6 +458,13 @@ pub struct Prefs { /// File Handling → Use Compression: native saves are gzip-compressed (`document.save /// {compress}` overrides it). pub use_compression: bool, + /// Save for Web: the user's presets (the built-in ones aren't stored). A local library, not a + /// Preferences dialog field: resetting the preferences keeps it; `webExport.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub web_export_presets: Vec, + /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. + #[serde(skip_serializing_if = "Option::is_none")] + pub web_export_settings: Option, } impl Default for Prefs { @@ -587,6 +594,8 @@ impl Default for Prefs { templates_folder: String::new(), append_converted: true, use_compression: false, + web_export_presets: vec![], + web_export_settings: None, } } } @@ -1245,6 +1254,8 @@ mod tests_transparencygrid; #[cfg(test)] mod tests_units; #[cfg(test)] +mod tests_webexport; +#[cfg(test)] mod tests_widthpoints; #[cfg(test)] mod tests_widthprofiles; diff --git a/crates/engine/src/tests_webexport.rs b/crates/engine/src/tests_webexport.rs new file mode 100644 index 00000000..8f845807 --- /dev/null +++ b/crates/engine/src/tests_webexport.rs @@ -0,0 +1,424 @@ +//! File → Export → Save for Web (Legacy): `document.exportForWeb`, its preview, the remembered +//! settings and the presets. + +use serde_json::{Value, json}; + +use super::*; + +/// The default settings as JSON. +fn defaults() -> Value { + cmd::webexport::WebSettings::default().to_json() +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A `w`×`h` pt document with unstroked art. +fn session(w: f64, h: f64) -> Session { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": w, "height": h})); + s.paint.stroke = vectorcraft_color::Paint::None; + s +} + +fn rect(s: &mut Session, [x, y, w, h]: [f64; 4], color: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"color": color})); + id +} + +/// 40×30: red on the left half, blue on the right (two colours, nothing transparent). +fn two_colours() -> Session { + let mut s = session(40.0, 30.0); + rect(&mut s, [0.0, 0.0, 20.0, 30.0], "#ff0000"); + rect(&mut s, [20.0, 0.0, 20.0, 30.0], "#0000ff"); + s +} + +/// 64×64: an 8×8 grid of 64 different colours. +fn many_colours() -> Session { + let mut s = session(64.0, 64.0); + for i in 0..64 { + let (x, y) = ((i % 8) as f64 * 8.0, (i / 8) as f64 * 8.0); + rect(&mut s, [x, y, 8.0, 8.0], &format!("#{:02x}{:02x}{:02x}", i * 4, 255 - i * 3, (i * 37) % 256)); + } + s +} + +/// The files of an export without a path: (name, bytes). +fn files(v: &Value) -> Vec<(String, Vec)> { + v["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +/// The one file of an export. +fn one(s: &mut Session, p: Value) -> Vec { + let f = files(&run(s, "document.exportForWeb", p)); + assert_eq!(f.len(), 1); + f[0].1.clone() +} + +fn pixels(file: &[u8]) -> image::RgbaImage { + image::load_from_memory(file).unwrap().to_rgba8() +} + +fn distinct(img: &image::RgbaImage) -> usize { + img.pixels().map(|p| p.0).collect::>().len() +} + +/// The transparent index of a GIF's graphic control extension, if it has one. +fn gif_transparent_index(gif: &[u8]) -> Option { + let i = gif.windows(3).position(|w| w == [0x21, 0xF9, 0x04])?; + (gif[i + 3] & 1 == 1).then_some(gif[i + 6]) +} + +#[test] +fn two_colours_give_a_two_colour_palette() { + let mut s = two_colours(); + for format in ["gif", "png8"] { + let p = json!({"format": format, "colors": 2, "antiAlias": "none"}); + let file = one(&mut s, p.clone()); + let img = pixels(&file); + assert_eq!(img.dimensions(), (40, 30)); + assert_eq!(distinct(&img), 2, "{format}"); + assert_eq!(img.get_pixel(5, 5).0, [255, 0, 0, 255]); + assert_eq!(img.get_pixel(35, 5).0, [0, 0, 255, 255]); + let pv = run(&mut s, "document.exportForWeb.preview", p); + let colors: Vec<&str> = pv["colors"].as_array().unwrap().iter().map(|c| c["color"].as_str().unwrap()).collect(); + assert_eq!(colors.len(), 2, "{format}: {colors:?}"); + assert!(colors.contains(&"#ff0000") && colors.contains(&"#0000ff")); + assert!(pv["colors"].as_array().unwrap().iter().all(|c| c["webSafe"] == true && c["transparent"] == false)); + } + // PNG-8 of two colours is one bit per pixel. + let png8 = one(&mut s, json!({"format": "png8", "colors": 2})); + assert_eq!(png8[24], 1); +} + +#[test] +fn colours_cap_the_palette_at_16() { + let mut s = many_colours(); + for format in ["gif", "png8"] { + for dither in ["none", "diffusion", "pattern", "noise"] { + for reduction in ["perceptual", "selective", "adaptive", "web"] { + let p = json!({"format": format, "colors": 16, "dither": dither, "reduction": reduction}); + let img = pixels(&one(&mut s, p.clone())); + assert!(distinct(&img) <= 16, "{p}: {}", distinct(&img)); + let pv = run(&mut s, "document.exportForWeb.preview", p.clone()); + assert!(pv["colors"].as_array().unwrap().len() <= 16, "{p}"); + } + } + } + // 128 colours keep all 64. + assert_eq!(distinct(&pixels(&one(&mut s, json!({"format": "png8", "colors": 128})))), 64); +} + +#[test] +fn gif_transparency_index_and_map_to_transparent() { + let mut s = session(40.0, 30.0); + rect(&mut s, [10.0, 5.0, 10.0, 20.0], "#ff0000"); + rect(&mut s, [20.0, 5.0, 10.0, 20.0], "#00ff00"); + let gif = one(&mut s, json!({"format": "gif", "antiAlias": "none"})); + assert_eq!(&gif[..6], b"GIF89a"); + assert_eq!(gif_transparent_index(&gif), Some(0), "the transparent entry is the first"); + let img = pixels(&gif); + assert_eq!(img.get_pixel(1, 1)[3], 0); + assert_eq!(img.get_pixel(15, 15).0, [255, 0, 0, 255]); + // Without transparency: no transparent index; the background is the matte. + let opaque = one(&mut s, json!({"format": "gif", "transparency": false, "matte": "#000000"})); + assert_eq!(gif_transparent_index(&opaque), None); + assert_eq!(pixels(&opaque).get_pixel(1, 1).0, [0, 0, 0, 255]); + // Map to Transparent: red goes, green stays. + let p = json!({"format": "gif", "antiAlias": "none", "colorTable": {"transparent": ["#ff0000"]}}); + let mapped = pixels(&one(&mut s, p.clone())); + assert_eq!(mapped.get_pixel(15, 15)[3], 0); + assert_eq!(mapped.get_pixel(25, 15).0, [0, 255, 0, 255]); + let pv = run(&mut s, "document.exportForWeb.preview", p); + assert_eq!(pv["mappedToTransparent"], json!(["#ff0000"])); + assert!(pv["colors"].as_array().unwrap().iter().all(|c| c["source"] != "#ff0000")); + // An opaque image gets a transparent index for a mapped colour. + let gif = one(&mut s, json!({"format": "gif", "transparency": false, "colorTable": {"transparent": ["#00ff00"]}})); + let i = gif_transparent_index(&gif).unwrap(); + assert_eq!(pixels(&gif).get_pixel(25, 15)[3], 0, "index {i}"); +} + +#[test] +fn jpeg_size_grows_with_quality() { + let mut s = many_colours(); + run(&mut s, "shape.ellipse", json!({"x": 5, "y": 7, "width": 41, "height": 33})); + let sizes: Vec = [0, 20, 40, 60, 80, 100].iter().map(|q| one(&mut s, json!({"format": "jpg", "quality": q})).len()).collect(); + assert!(sizes.windows(2).all(|w| w[0] <= w[1]), "{sizes:?}"); + assert!(sizes[0] < sizes[5], "{sizes:?}"); + let jpg = one(&mut s, json!({"format": "jpeg", "quality": 80})); + assert_eq!(&jpg[..2], &[0xFF, 0xD8]); + // Transparent areas are on the matte. + let mut t = session(40.0, 30.0); + rect(&mut t, [0.0, 0.0, 10.0, 10.0], "#000000"); + let yellow = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] < 20; + let img = pixels(&one(&mut t, json!({"format": "jpg", "quality": 100, "matte": "#ffff00", "optimized": false}))); + assert!(yellow(img.get_pixel(30, 20).0), "{:?}", img.get_pixel(30, 20)); + // The preview shows the pixels of an optimised file (decoded from a baseline twin: the + // `image` crate misreads some optimised Huffman tables). + let st = cmd::webexport::WebSettings { format: cmd::webexport::WebFormat::Jpg, quality: 90, matte: "#ffff00".into(), ..Default::default() }; + let doc = &t.doc().unwrap().doc; + let r = cmd::webexport::render(doc, &st).unwrap(); + let o = cmd::webexport::preview_image(doc, &r.rgba, r.width, r.height, &st).unwrap(); + assert_eq!(o.bytes, cmd::webexport::optimize(doc, &r.rgba, r.width, r.height, &st).unwrap().bytes, "the file is the one Save writes"); + let (px, w, h) = cmd::webexport::decode(&o).unwrap(); + assert_eq!((w, h), (40, 30)); + let at = (20 * 40 + 30) * 4; + assert!(yellow([px[at], px[at + 1], px[at + 2], px[at + 3]]), "{:?}", &px[at..at + 4]); + // Progressive frames, and the sRGB profile when asked. + let prog = one(&mut t, json!({"format": "jpg", "progressive": true, "embedProfile": true})); + assert!(prog.windows(2).any(|w| w == [0xFF, 0xC2])); + assert!(prog.windows(12).any(|w| w == b"ICC_PROFILE\0")); +} + +#[test] +fn preview_size_is_the_written_size() { + let mut s = many_colours(); + for p in [ + json!({"format": "gif", "colors": 32, "lossy": 40}), + json!({"format": "png8", "colors": 64, "webSnap": 50, "interlaced": true}), + json!({"format": "png24", "transparency": false}), + json!({"format": "jpg", "quality": 35, "metadata": "all"}), + json!({"format": "gif", "percent": 50, "colorTable": {"sort": "luminance"}}), + ] { + let file = one(&mut s, p.clone()); + let pv = run(&mut s, "document.exportForWeb.preview", merge(&p, json!({"image": true}))); + assert_eq!(pv["bytes"].as_u64().unwrap() as usize, file.len(), "{p}"); + assert_eq!(vectorcraft_format::base64_decode(pv["dataBase64"].as_str().unwrap()).unwrap(), file, "{p}"); + let img = pixels(&file); + assert_eq!((pv["width"].as_u64().unwrap() as u32, pv["height"].as_u64().unwrap() as u32), img.dimensions()); + let seconds = pv["seconds"].as_f64().unwrap(); + assert!((seconds - file.len() as f64 * 8.0 / 56_600.0).abs() < 1e-9, "at 56.6 kbps by default"); + } + let pv = run(&mut s, "document.exportForWeb.preview", json!({"kbps": 1000})); + assert!((pv["seconds"].as_f64().unwrap() * 125_000.0 - pv["bytes"].as_f64().unwrap()).abs() < 1e-6); + assert!(s.execute("document.exportForWeb.preview", &json!({"kbps": 0})).is_err()); +} + +fn merge(a: &Value, b: Value) -> Value { + let mut a = a.clone(); + if let (Some(o), Value::Object(b)) = (a.as_object_mut(), b) { + o.extend(b); + } + a +} + +#[test] +fn slices_give_one_image_each_and_a_page() { + let mut s = session(300.0, 200.0); + rect(&mut s, [0.0, 0.0, 300.0, 200.0], "#336699"); + let a = rect(&mut s, [20.0, 20.0, 100.0, 60.0], "#ff8800"); + run(&mut s, "object.slice.make", json!({"ids": [a]})); + run(&mut s, "object.slice.options", json!({"name": "logo", "url": "https://example.com/", "alt": "Logo <1>", "target": "_blank"})); + let b = run(&mut s, "object.slice.create", json!({"x": 150, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap(); + let layout = run(&mut s, "slice.list", json!({}))["slices"].as_array().unwrap().clone(); + let n = layout.len(); + assert!(n > 2, "auto slices fill the rest: {n}"); + + let r = run(&mut s, "document.exportForWeb", json!({"format": "gif", "output": "html"})); + let f = files(&r); + assert_eq!(f.len(), n + 1, "N images plus the page"); + assert_eq!(f[0].0, "Untitled-1.html"); + assert!(f[1..].iter().all(|(name, bytes)| name.starts_with("images/") && name.ends_with(".gif") && bytes.starts_with(b"GIF"))); + assert!(f.iter().any(|(name, _)| name == "images/logo.gif")); + let html = String::from_utf8(f[0].1.clone()).unwrap(); + assert_eq!(html.matches("Hi", "background": "#ffffff", "hAlign": "center"})); + let f = files(&run(&mut s, "document.exportForWeb", json!({"output": "html"}))); + assert_eq!(f.len(), n); + let html = String::from_utf8(f[0].1.clone()).unwrap(); + assert!(html.contains("Hi") && html.contains("background: #ffffff;") && html.contains("justify-content: center")); + // Selected slices only. + run(&mut s, "object.slice.select", json!({"slices": [a]})); + let f = files(&run(&mut s, "document.exportForWeb", json!({"slices": "selected"}))); + assert_eq!(f.iter().map(|(n, _)| n.as_str()).collect::>(), ["images/logo.gif"]); + run(&mut s, "object.slice.select", json!({})); + assert!(s.execute("document.exportForWeb", &json!({"slices": "selected"})).is_err()); + // No slices: one image. + assert_eq!(files(&run(&mut s, "document.exportForWeb", json!({"slices": "none"}))).len(), 1); + assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["slices"], n - 1); + let one_slice = run(&mut s, "document.exportForWeb.preview", json!({"slice": layout[0]["number"]})); + assert_eq!((one_slice["width"].as_f64(), one_slice["height"].as_f64()), (layout[0]["width"].as_f64(), layout[0]["height"].as_f64())); +} + +#[test] +fn files_are_written_beside_the_path() { + let dir = vectorcraft_testkit::temp_dir("webexport"); + let mut s = two_colours(); + let path = dir.join("art.gif").to_string_lossy().to_string(); + let r = run(&mut s, "document.exportForWeb", json!({"path": path})); + assert_eq!(r["files"], json!([path])); + assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a")); + let page = dir.join("page.html").to_string_lossy().to_string(); + let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html", "format": "png24"})); + assert_eq!(r["path"], json!(page)); + assert!(std::fs::read_to_string(&page).unwrap().contains("src=\"images/page.png\"")); + assert!(dir.join("images").join("page.png").exists()); + assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len()); +} + +#[test] +fn image_size_region_and_metadata() { + let mut s = two_colours(); + let size = |s: &mut Session, p: Value| pixels(&one(s, p)).dimensions(); + assert_eq!(size(&mut s, json!({"width": 80})), (80, 60)); + assert_eq!(size(&mut s, json!({"height": 15})), (20, 15)); + assert_eq!(size(&mut s, json!({"percent": 150})), (60, 45)); + for bad in [ + json!({"percent": 0}), + json!({"width": 0}), + json!({"format": "bmp"}), + json!({"colors": "many"}), + json!({"matte": "plaid"}), + json!({"colorTable": {"locked": ["nope"]}}), + json!({"artboard": 3}), + json!({"preset": "Nope"}), + ] { + assert!(s.execute("document.exportForWeb", &bad).is_err(), "{bad}"); + } + // Clip to Artboard off: the art's bounds. + let mut t = session(100.0, 100.0); + rect(&mut t, [10.0, 20.0, 30.0, 15.0], "#000000"); + assert_eq!(size(&mut t, json!({"clipToArtboard": false})), (30, 15)); + assert_eq!(size(&mut t, json!({})), (100, 100)); + // File Info: the copyright by default, everything with all, nothing with none. + run(&mut s, "file.info", json!({"copyrightNotice": "(c) Someone", "author": "Ann", "description": "Flags"})); + let png = one(&mut s, json!({"format": "png24"})); + let has = |f: &[u8], t: &str| f.windows(t.len()).any(|w| w == t.as_bytes()); + assert!(has(&png, "(c) Someone") && !has(&png, "Ann")); + assert!(has(&png, "sRGB"), "converted to sRGB"); + assert!(!has(&one(&mut s, json!({"format": "png24", "convertToSrgb": false})), "sRGB")); + let all = one(&mut s, json!({"format": "png8", "metadata": "all"})); + assert!(has(&all, "Ann") && has(&all, "Flags")); + assert!(has(&one(&mut s, json!({"format": "png24", "metadata": "contact"})), "Ann")); + assert!(!has(&one(&mut s, json!({"format": "gif", "metadata": "none"})), "Someone")); + assert!(has(&one(&mut s, json!({"format": "gif"})), "Copyright: (c) Someone")); + assert!(has(&one(&mut s, json!({"format": "jpg"})), "Copyright: (c) Someone")); +} + +#[test] +fn colour_table_lock_web_shift_snap_and_sort() { + let mut s = session(30.0, 10.0); + rect(&mut s, [0.0, 0.0, 10.0, 10.0], "#123456"); + rect(&mut s, [10.0, 0.0, 10.0, 10.0], "#fe0101"); + rect(&mut s, [20.0, 0.0, 10.0, 10.0], "#808080"); + let colors = |s: &mut Session, p: Value| -> Vec { run(s, "document.exportForWeb.preview", p)["colors"].as_array().unwrap().clone() }; + let base = colors(&mut s, json!({})); + assert_eq!(base.len(), 3); + assert!(base.iter().all(|c| c["color"] == c["source"])); + // Web Shift one colour by its source. + let shifted = colors(&mut s, json!({"colorTable": {"webShift": ["#123456"]}})); + let c = shifted.iter().find(|c| c["source"] == "#123456").unwrap(); + assert_eq!((c["color"].as_str(), c["webShifted"].as_bool(), c["webSafe"].as_bool()), (Some("#003366"), Some(true), Some(true))); + // Web Snap: near colours snap, far ones don't; locked ones never do. + let snapped = colors(&mut s, json!({"webSnap": 5})); + assert!(snapped.iter().any(|c| c["source"] == "#fe0101" && c["color"] == "#ff0000")); + assert!(snapped.iter().any(|c| c["source"] == "#123456" && c["color"] == "#123456")); + let locked = colors(&mut s, json!({"webSnap": 100, "colorTable": {"locked": ["#fe0101"]}})); + let c = locked.iter().find(|c| c["source"] == "#fe0101").unwrap(); + assert_eq!((c["color"].as_str(), c["locked"].as_bool()), (Some("#fe0101"), Some(true))); + // Locked colours survive fewer colours. + let two = colors(&mut s, json!({"colors": 2, "dither": "none", "colorTable": {"locked": ["#808080"]}})); + assert!(two.len() <= 2 && two.iter().any(|c| c["color"] == "#808080"), "{two:?}"); + // Sorting by luminance. + let sorted = colors(&mut s, json!({"colorTable": {"sort": "luminance"}})); + let names: Vec<&str> = sorted.iter().map(|c| c["color"].as_str().unwrap()).collect(); + assert_eq!(names, ["#123456", "#fe0101", "#808080"]); + // Lossy makes smaller GIFs of busy art. + let mut m = many_colours(); + let plain = one(&mut m, json!({"colors": 32})).len(); + let lossy = one(&mut m, json!({"colors": 32, "lossy": 100})).len(); + assert!(lossy < plain, "{lossy} vs {plain}"); +} + +#[test] +fn presets_and_remembered_settings() { + let mut s = two_colours(); + let list = run(&mut s, "webExport.presets.list", json!({})); + let names: Vec<&str> = list["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert!(names.len() >= 6 && list["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); + // A built-in preset as a starting point. + let jpg = one(&mut s, json!({"preset": "JPEG, quality 80"})); + assert_eq!(&jpg[..2], &[0xFF, 0xD8]); + assert!(one(&mut s, json!({"preset": "jpeg, QUALITY 80", "quality": 10})).len() < jpg.len(), "keys win over the preset"); + + let r = run(&mut s, "webExport.presets.save", json!({"name": "Mine", "format": "png8", "colors": 4})); + assert_eq!((r["created"].as_bool(), r["settings"]["colors"].as_u64()), (Some(true), Some(4))); + let r = run(&mut s, "webExport.presets.save", json!({"name": "mine", "settings": {"interlaced": true}})); + assert_eq!((r["created"].as_bool(), r["settings"]["colors"].as_u64(), r["settings"]["interlaced"].as_bool()), (Some(false), Some(4), Some(true))); + let png = one(&mut s, json!({"preset": "Mine"})); + assert_eq!((png[25], png[28]), (3, 1), "indexed and interlaced"); + run(&mut s, "webExport.presets.save", json!({"name": "Mine", "newName": "Ours"})); + assert!(s.execute("webExport.presets.save", &json!({"name": "PNG-24"})).is_err(), "built in"); + assert!(s.execute("webExport.presets.save", &json!({"name": "Ours", "newName": "PNG-24"})).is_err(), "taken"); + assert!(s.execute("webExport.presets.save", &json!({})).is_err()); + assert!(s.execute("webExport.presets.delete", &json!({"name": "PNG-24"})).is_err()); + // Saved presets are preferences (not Preferences dialog keys) and survive a reset. + let saved: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(saved, s.prefs, "round trip"); + assert_eq!(s.prefs.to_json()["webExportPresets"][0]["name"], "Ours"); + assert!(Prefs::default().to_json().get("webExportPresets").is_none() && Prefs::default().to_json().get("webExportSettings").is_none()); + run(&mut s, "prefs.reset", json!({})); + assert_eq!(s.prefs.web_export_presets.len(), 1); + assert_eq!(run(&mut s, "webExport.presets.delete", json!({"name": "ours"}))["deleted"], "Ours"); + assert!(s.execute("webExport.presets.delete", &json!({"name": "Ours"})).is_err()); + + // The dialog's remembered settings. + assert_eq!(run(&mut s, "webExport.settings", json!({}))["settings"], defaults()); + assert!(s.prefs.web_export_settings.is_none(), "reading stores nothing"); + run(&mut s, "webExport.settings", json!({"format": "jpg", "quality": 30})); + let r = run(&mut s, "webExport.settings", json!({"progressive": true})); + assert_eq!( + (r["settings"]["format"].as_str(), r["settings"]["quality"].as_u64(), r["settings"]["progressive"].as_bool()), + (Some("jpg"), Some(30), Some(true)) + ); + assert_eq!(run(&mut s, "webExport.settings", json!({"preset": "PNG-24"}))["settings"]["format"], "png24"); + assert_eq!(run(&mut s, "webExport.settings", json!({"reset": true}))["settings"], defaults()); + assert!(s.execute("webExport.settings", &json!({"quality": 500})).is_err()); +} + +#[test] +fn setting_keys_are_their_json_values() { + use cmd::webexport::{SliceScope, WebFormat, WebMetadata, WebOutput}; + for f in WebFormat::ALL { + assert_eq!(serde_json::to_value(f).unwrap(), json!(f.id())); + } + for m in WebMetadata::ALL { + assert_eq!(serde_json::to_value(m).unwrap(), json!(m.key())); + } + for s in SliceScope::ALL { + assert_eq!(serde_json::to_value(s).unwrap(), json!(s.key())); + } + for o in WebOutput::ALL { + assert_eq!(serde_json::to_value(o).unwrap(), json!(o.key())); + } + // Every setting round-trips through JSON (presets, the remembered settings). + let s = Session::new(); + let custom = s + .web_settings( + &json!({"format": "png8", "reduction": "web", "dither": "pattern", "antiAlias": "type", "colorTable": {"sort": "hue"}, "percent": 50}), + ) + .unwrap(); + assert_eq!(serde_json::from_value::(custom.to_json()).unwrap(), custom); +} diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index 4140a560..eb88a5f6 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -2,7 +2,8 @@ //! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG //! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or //! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte -//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]. +//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]; [`web`] adds what Save for Web optimises +//! further (web snap, colour table edits, lossy GIF, comments). pub mod bmp; pub mod gif; @@ -11,6 +12,7 @@ pub mod png; pub mod quantize; pub mod tga; pub mod tiff; +pub mod web; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; @@ -200,3 +202,5 @@ mod tests_palette; mod tests_tga; #[cfg(test)] mod tests_tiff; +#[cfg(test)] +mod tests_web; diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index 7e7e12af..f55fd316 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -135,38 +135,54 @@ fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8; Ok(out) } +/// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first. +const HEADER: usize = 8 + 12 + 13; + /// `png` (a PNG file) with a text chunk per `(keyword, text)` right after its header: `tEXt`, or /// uncompressed `iTXt` (UTF-8) for text beyond Latin-1. Keywords are 1–79 printable ASCII /// characters (others are skipped). Unchanged when it doesn't start with a PNG header. pub fn with_text(png: Vec, entries: &[(&str, Cow)]) -> Vec { - // The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first. - const HEADER: usize = 8 + 12 + 13; - if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER { + let chunks: Vec<([u8; 4], Vec)> = entries + .iter() + .filter(|(keyword, _)| !keyword.is_empty() && keyword.len() <= 79 && keyword.bytes().all(|b| (32..=126).contains(&b))) + .map(|(keyword, text)| { + let mut data = keyword.as_bytes().to_vec(); + data.push(0); + let latin1: Option> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect(); + match latin1 { + Some(bytes) => { + data.extend(bytes); + (*b"tEXt", data) + } + None => { + // Uncompressed, no language tag, no translated keyword. + data.extend_from_slice(&[0, 0, 0, 0]); + data.extend_from_slice(text.as_bytes()); + (*b"iTXt", data) + } + } + }) + .collect(); + with_chunks(png, &chunks) +} + +/// `png` marked as sRGB (an `sRGB` chunk, perceptual intent) right after its header, so browsers +/// show its colours as they are. Unchanged when it doesn't start with a PNG header. +pub fn with_srgb(png: Vec) -> Vec { + with_chunks(png, &[(*b"sRGB", vec![0])]) +} + +/// `png` with `chunks` (type, data) right after its header; chunks too large for a PNG are +/// skipped. Unchanged when there are none or it doesn't start with a PNG header. +fn with_chunks(png: Vec, chunks: &[([u8; 4], Vec)]) -> Vec { + if chunks.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER { return png; } - let mut out = Vec::with_capacity(png.len() + entries.iter().map(|(k, v)| k.len() + v.len() + 20).sum::()); + let mut out = Vec::with_capacity(png.len() + chunks.iter().map(|(_, d)| d.len() + 12).sum::()); out.extend_from_slice(&png[..HEADER]); - for (keyword, text) in entries { - if keyword.is_empty() || keyword.len() > 79 || !keyword.bytes().all(|b| (32..=126).contains(&b)) { - continue; - } - let mut data = keyword.as_bytes().to_vec(); - data.push(0); - let latin1: Option> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect(); - let ty = match latin1 { - Some(bytes) => { - data.extend(bytes); - b"tEXt" - } - None => { - // Uncompressed, no language tag, no translated keyword. - data.extend_from_slice(&[0, 0, 0, 0]); - data.extend_from_slice(text.as_bytes()); - b"iTXt" - } - }; + for (ty, data) in chunks { if u32::try_from(data.len()).is_ok() { - chunk(&mut out, ty, &data); + chunk(&mut out, ty, data); } } out.extend_from_slice(&png[HEADER..]); diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs index e2015f5b..cfebcc9c 100644 --- a/crates/render/src/encode/quantize.rs +++ b/crates/render/src/encode/quantize.rs @@ -145,6 +145,13 @@ pub struct Indexed { /// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette. pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed { + quantize_locked(rgba, width, height, o, &[]) +} + +/// [`quantize`], keeping the `locked` colours in a reduced palette (the colour table's locked +/// colours: reducing the colours further never drops them). Art with few enough colours keeps +/// them all anyway; locked colours no pixel ends up using are left out. +pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> 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); @@ -162,15 +169,29 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind 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); + // Locked colours take their slots first (at least one is left for the reduction). + let mut lock: Vec<[u8; 3]> = Vec::with_capacity(locked.len().min(k)); + for c in locked { + if lock.len() + 1 < k && !lock.contains(c) { + lock.push(*c); + } + } + let free = k - lock.len(); 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), + Reduction::Gray => ((0..free).map(|i| [(i * 255 / (free - 1).max(1)) as u8; 3]).collect(), false), + Reduction::Web => (web(opaque(), free), false), r => match distinct(opaque(), k) { Some(exact) => (exact, true), - None => (median_cut(opaque(), k, r), false), + None => (median_cut(opaque(), free, r), false), }, }; + if !exact && !lock.is_empty() { + palette.retain(|c| !lock.contains(c)); + lock.append(&mut palette); + lock.truncate(k); + palette = lock; + } if palette.is_empty() { palette.push(matte); } @@ -221,7 +242,7 @@ fn distinct(colors: impl Iterator, k: usize) -> Option [u8; 3] { +pub fn web_safe(c: [u8; 3]) -> [u8; 3] { c.map(|v| ((v as u32 + 25) / 51 * 51) as u8) } diff --git a/crates/render/src/encode/tests_web.rs b/crates/render/src/encode/tests_web.rs new file mode 100644 index 00000000..e0e60037 --- /dev/null +++ b/crates/render/src/encode/tests_web.rs @@ -0,0 +1,109 @@ +use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_locked}; +use super::web::{ColorSort, gif_comment, jpeg_comment, lossy, snap, sort, to_transparent}; +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)| [f(x, y)[0], f(x, y)[1], f(x, y)[2], 255]).collect() +} + +fn colours(ix: &Indexed) -> Vec<[u8; 3]> { + ix.indices.iter().map(|&i| ix.palette[i as usize]).collect() +} + +#[test] +fn locked_colours_survive_a_reduction() { + let px = image(64, 16, |x, y| [(x * 4) as u8, (y * 16) as u8, 90]); + let rare = [7, 200, 13]; + let mut px = px; + px[..4].copy_from_slice(&[rare[0], rare[1], rare[2], 255]); + let o = PaletteOptions { colors: 4, dither: Dither::None, reduction: Reduction::Adaptive, ..Default::default() }; + assert!(!quantize(&px, 64, 16, &o).palette.contains(&rare), "a lone pixel loses its colour"); + let ix = quantize_locked(&px, 64, 16, &o, &[rare]); + assert!(ix.palette.contains(&rare) && ix.palette.len() <= 4, "{:?}", ix.palette); + assert_eq!(colours(&ix)[0], rare); +} + +#[test] +fn snapping_to_web_colours() { + assert_eq!(snap([50, 103, 0], 0), None); + assert_eq!(snap([50, 103, 0], 10), Some([51, 102, 0])); + assert_eq!(snap([60, 103, 0], 10), None, "9 levels off is beyond 10%"); + assert_eq!(snap([60, 103, 0], 100), Some([51, 102, 0])); + assert_eq!(snap([25, 25, 25], 100), Some([0, 0, 0])); +} + +#[test] +fn mapped_colours_become_the_transparent_entry() { + let px = image(4, 1, |x, _| [[255, 0, 0], [0, 0, 255], [0, 255, 0], [255, 0, 0]][x as usize]); + let mut ix = quantize(&px, 4, 1, &PaletteOptions::default()); + assert_eq!(ix.transparent, None); + let red = ix.palette.iter().position(|c| *c == [255, 0, 0]).unwrap(); + let map = to_transparent(&mut ix, |i| i == red); + assert_eq!(map[red], None); + assert_eq!(ix.transparent, Some(0)); + assert_eq!(ix.palette.len(), 3, "the transparent entry and blue and green"); + assert_eq!((ix.indices[0], ix.indices[3]), (0, 0)); + assert_eq!(ix.palette[ix.indices[1] as usize], [0, 0, 255]); + // Mapping nothing changes nothing. + let before = ix.clone(); + to_transparent(&mut ix, |_| false); + assert_eq!(ix, before); +} + +#[test] +fn sorting_keeps_every_pixel_colour() { + let px: Vec = image(8, 8, |x, y| [(x * 30) as u8, 255 - (y * 30) as u8, ((x + y) * 15) as u8]) + .chunks(4) + .enumerate() + .flat_map(|(i, p)| [p[0], p[1], p[2], if i == 5 { 0 } else { 255 }]) + .collect(); + let ix = quantize(&px, 8, 8, &PaletteOptions { colors: 16, ..Default::default() }); + for order in ColorSort::ALL { + let mut s = ix.clone(); + let new = sort(&mut s, order); + assert_eq!(s.transparent, Some(0), "{order:?}: the transparent entry stays first"); + assert_eq!(new[0], 0); + for (i, (&a, &b)) in ix.indices.iter().zip(&s.indices).enumerate() { + assert_eq!(ix.palette[a as usize], s.palette[b as usize], "{order:?} pixel {i}"); + } + } + let mut s = ix.clone(); + sort(&mut s, ColorSort::Luminance); + let luma = |c: &[u8; 3]| 299 * c[0] as u32 + 587 * c[1] as u32 + 114 * c[2] as u32; + assert!(s.palette[1..].windows(2).all(|w| luma(&w[0]) <= luma(&w[1]))); + assert_eq!(ColorSort::from_id("popularity"), Some(ColorSort::Popularity)); +} + +#[test] +fn lossy_makes_runs_and_smaller_gifs() { + let px = image(96, 64, |x, y| [(x * 2 + y) as u8, (y * 3) as u8, ((x * y) % 255) as u8]); + let ix = quantize(&px, 96, 64, &PaletteOptions { colors: 64, dither: Dither::Diffusion, ..Default::default() }); + let mut lossy_ix = ix.clone(); + lossy(&mut lossy_ix, &px, 80); + let runs = |ix: &Indexed| ix.indices.windows(2).filter(|w| w[0] != w[1]).count(); + assert!(runs(&lossy_ix) < runs(&ix)); + let size = |ix: &Indexed| gif::encode(ix, false).unwrap().len(); + assert!(size(&lossy_ix) < size(&ix), "{} vs {}", size(&lossy_ix), size(&ix)); + let mut none = ix.clone(); + lossy(&mut none, &px, 0); + assert_eq!(none, ix); +} + +#[test] +fn comments_are_valid_files() { + let ix = quantize(&image(5, 4, |x, _| [x as u8 * 40, 0, 0]), 5, 4, &PaletteOptions::default()); + let file = gif_comment(gif::encode(&ix, false).unwrap(), &"Copyright: Someone ".repeat(20)); + assert!(file.windows(2).any(|w| w == [0x21, 0xFE])); + assert_eq!(image::load_from_memory(&file).unwrap().width(), 5); + let px = vec![200u8; 5 * 4 * 3]; + let jpg = jpeg::encode(&px, 5, 4, 80, Some(72.0), &jpeg::JpegOptions::default()).unwrap(); + let with = jpeg_comment(jpg.clone(), "Copyright: Someone"); + assert_eq!(with.len(), jpg.len() + 4 + 18); + assert!(with.windows(2).any(|w| w == [0xFF, 0xFE])); + assert_eq!(image::load_from_memory(&with).unwrap().height(), 4); + assert_eq!(jpeg_comment(vec![1, 2, 3], "x"), vec![1, 2, 3], "not a JPEG"); + let png = png::with_srgb(png::encode(&[0, 0, 0, 255], 1, 1, &png::PngOptions::default()).unwrap()); + assert!(png.windows(4).any(|w| w == b"sRGB")); + assert_eq!(image::load_from_memory(&png).unwrap().width(), 1); +} diff --git a/crates/render/src/encode/web.rs b/crates/render/src/encode/web.rs new file mode 100644 index 00000000..2a26a336 --- /dev/null +++ b/crates/render/src/encode/web.rs @@ -0,0 +1,215 @@ +//! Web image optimisation on top of [`super::quantize`] (Save for Web): web snap, the colour +//! table's edits (map to transparent, sort), lossy GIF compression, and comments in GIF and JPEG +//! files. + +use super::quantize::{Indexed, web_safe}; + +/// How the colour table is ordered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorSort { + /// As the reduction made it. + #[default] + None, + Hue, + Luminance, + /// Most used first. + Popularity, +} + +impl ColorSort { + pub const ALL: [ColorSort; 4] = [ColorSort::None, ColorSort::Hue, ColorSort::Luminance, ColorSort::Popularity]; + + pub fn id(self) -> &'static str { + match self { + ColorSort::None => "none", + ColorSort::Hue => "hue", + ColorSort::Luminance => "luminance", + ColorSort::Popularity => "popularity", + } + } + + pub fn label(self) -> &'static str { + match self { + ColorSort::None => "Unsorted", + ColorSort::Hue => "Sort by Hue", + ColorSort::Luminance => "Sort by Luminance", + ColorSort::Popularity => "Sort by Popularity", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(s) || v.label().eq_ignore_ascii_case(s)) + } +} + +/// `c` as its nearest web-safe colour when every channel is within `tolerance` percent (0–100) of +/// it: 100 snaps every colour, 0 none. +pub fn snap(c: [u8; 3], tolerance: u8) -> Option<[u8; 3]> { + // The farthest a channel can be from a web-safe value is 25 (half the step of 51). + let max = u32::from(tolerance.min(100)) * 26 / 100; + let s = web_safe(c); + (tolerance > 0 && (0..3).all(|i| u32::from(c[i].abs_diff(s[i])) <= max)).then_some(s) +} + +/// Make the palette entries `mapped` names (by index) transparent: their pixels use the +/// transparent entry (added at index 0 when there is none) and the entries go. Returns the old +/// index → new index map (`None`: the entry went). +pub fn to_transparent(ix: &mut Indexed, mapped: impl Fn(usize) -> bool) -> Vec> { + let n = ix.palette.len(); + let gone: Vec = (0..n).map(|i| Some(i) != ix.transparent.map(usize::from) && mapped(i)).collect(); + if !gone.contains(&true) { + return (0..n).map(|i| u8::try_from(i).ok()).collect(); + } + let clear = ix.transparent.map(usize::from); + let mut palette = Vec::with_capacity(n); + // The transparent entry first: the old one, or a new one (the first entry mapped's colour). + let first_gone = gone.iter().position(|g| *g).unwrap_or(0); + palette.push(ix.palette.get(clear.unwrap_or(first_gone)).copied().unwrap_or_default()); + let mut map = vec![Some(0u8); n]; + for (i, c) in ix.palette.iter().enumerate() { + if gone[i] || Some(i) == clear { + continue; + } + map[i] = u8::try_from(palette.len()).ok(); + palette.push(*c); + } + for v in &mut ix.indices { + *v = map.get(*v as usize).copied().flatten().unwrap_or(0); + } + ix.palette = palette; + ix.transparent = Some(0); + map.into_iter().enumerate().map(|(i, m)| if gone[i] { None } else { m }).collect() +} + +/// Lossy GIF compression: a pixel takes the entry of the pixel left of it when that entry is +/// within `amount` (0–100) of its own colour in `rgba` (the straight pixels it was reduced from). +/// Longer runs of one index compress better in a GIF, at the cost of some noise. +pub fn lossy(ix: &mut Indexed, rgba: &[u8], amount: u8) { + let w = ix.width as usize; + if amount == 0 || w == 0 || rgba.len() != ix.indices.len() * 4 { + return; + } + // Up to 64 levels apart in each channel at 100. + let limit = (f32::from(amount.min(100)) / 100.0 * 64.0).powi(2) * 3.0; + let px = rgba.as_chunks::<4>().0; + for (row, src) in ix.indices.chunks_mut(w).zip(px.chunks(w)) { + for x in 1..row.len() { + let (prev, cur) = (row[x - 1], row[x]); + if prev == cur || ix.transparent.is_some_and(|t| t == prev || t == cur) { + continue; + } + let (Some(c), Some(p)) = (src.get(x), ix.palette.get(prev as usize)) else { continue }; + let d: f32 = (0..3).map(|i| (f32::from(c[i]) - f32::from(p[i])).powi(2)).sum(); + if d <= limit { + row[x] = prev; + } + } + } +} + +/// Reorder the palette by `order`, the transparent entry staying first. Returns the new order (the +/// old index of each entry). +pub fn sort(ix: &mut Indexed, order: ColorSort) -> Vec { + let n = ix.palette.len(); + let identity: Vec = (0..n).collect(); + if order == ColorSort::None || n < 2 { + return identity; + } + let mut count = vec![0u64; n]; + for &v in &ix.indices { + if let Some(c) = count.get_mut(v as usize) { + *c += 1; + } + } + let fixed = ix.transparent.map(usize::from); + let mut rest: Vec = identity.into_iter().filter(|i| Some(*i) != fixed).collect(); + let key = |i: usize| -> (f32, f32) { + let [r, g, b] = ix.palette.get(i).copied().unwrap_or_default().map(|v| f32::from(v) / 255.0); + let luma = 0.299 * r + 0.587 * g + 0.114 * b; + match order { + ColorSort::Hue => (hue(r, g, b), luma), + ColorSort::Luminance => (luma, 0.0), + _ => (-(count.get(i).copied().unwrap_or(0) as f32), luma), + } + }; + rest.sort_by(|a, b| { + let (x, y) = (key(*a), key(*b)); + x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)).then(a.cmp(b)) + }); + let order: Vec = fixed.into_iter().chain(rest).collect(); + let mut remap = vec![0u8; n]; + for (new, &old) in order.iter().enumerate() { + if let (Some(r), Ok(v)) = (remap.get_mut(old), u8::try_from(new)) { + *r = v; + } + } + ix.palette = order.iter().filter_map(|&i| ix.palette.get(i).copied()).collect(); + for v in &mut ix.indices { + *v = remap.get(*v as usize).copied().unwrap_or(0); + } + if fixed.is_some() { + ix.transparent = Some(0); + } + order +} + +/// Hue in 0..1 (greys first, at -1). +fn hue(r: f32, g: f32, b: f32) -> f32 { + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let d = max - min; + if d <= f32::EPSILON { + return -1.0; + } + let h = if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + h / 6.0 +} + +/// `gif` with a comment extension holding `text` before its trailer (unchanged when it isn't a +/// GIF or `text` is empty). +pub fn gif_comment(mut gif: Vec, text: &str) -> Vec { + if text.is_empty() || !gif.starts_with(b"GIF8") || gif.last() != Some(&0x3B) { + return gif; + } + gif.pop(); + gif.extend_from_slice(&[0x21, 0xFE]); + for block in text.as_bytes().chunks(255) { + gif.push(block.len() as u8); + gif.extend_from_slice(block); + } + gif.extend_from_slice(&[0x00, 0x3B]); + gif +} + +/// `jpeg` with a comment segment (`COM`) holding `text` after its JFIF header (unchanged when it +/// isn't a JPEG or `text` is empty; text past a segment's 65 533 bytes is cut at a character). +pub fn jpeg_comment(jpeg: Vec, text: &str) -> Vec { + if text.is_empty() || !jpeg.starts_with(&[0xFF, 0xD8]) { + return jpeg; + } + // After SOI, and after the APP0 (JFIF) segment when it comes first. + let at = match jpeg.get(2..6) { + Some([0xFF, 0xE0, hi, lo]) => 4 + usize::from(u16::from_be_bytes([*hi, *lo])), + _ => 2, + }; + if at > jpeg.len() { + return jpeg; + } + let mut end = text.len().min(65_533); + while !text.is_char_boundary(end) { + end -= 1; + } + let data = &text.as_bytes()[..end]; + let mut out = Vec::with_capacity(jpeg.len() + data.len() + 4); + out.extend_from_slice(&jpeg[..at]); + out.extend_from_slice(&[0xFF, 0xFE]); + out.extend_from_slice(&((data.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(&jpeg[at..]); + out +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 39a121ff..9ba801d9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -564,7 +564,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result (egui::TextureHandle, f32) { +pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) { let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); let id = egui::Id::new("transparency-grid-tile"); let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 31c453b7..0cbadb69 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -46,6 +46,7 @@ pub mod recolor; mod recovery; pub mod saturate; mod save_changes; +pub mod save_for_web; pub(crate) mod save_options; mod save_pdf; pub mod save_style_library; @@ -229,6 +230,7 @@ registry! { Recovery: [crate::recovery::KIND] => recovery::SPEC, DxfImport: [dxf_import::KIND] => dxf_import::SPEC, RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC, + SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -352,3 +354,6 @@ mod tests_metafile; #[cfg(test)] mod tests_tiff_bmp_tga; + +#[cfg(test)] +mod tests_save_for_web; diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs new file mode 100644 index 00000000..6b9dccbf --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -0,0 +1,832 @@ +//! File → Export → Save for Web (Legacy) (`saveForWeb`, Ctrl/Cmd+Alt+Shift+S): the art previewed +//! as the web image Save writes, beside the original (Original, Optimized, 2-Up and 4-Up views, +//! each with its file size and download time), and the settings: format (GIF, JPEG, PNG-8, +//! PNG-24), colour reduction, colours, dither, transparency and matte, interlacing, web snap, +//! lossy, JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size +//! and anti-aliasing, Clip to Artboard, sRGB, metadata, slices and HTML output, and presets. +//! +//! Fields are the `document.exportForWeb` settings (`webExport.settings` gives them; set one with +//! `ui.dialog.set`, or `preset` to load a preset), plus the dialog's own: `__view` (`original`, +//! `optimized`, `2up`, `4up`), `__zoom` (`fit` or a percentage), `__kbps` (connection speed), +//! `__color` (the colour table's selected colour, by source), `__presetName` (shown while saving a +//! preset) and `__hasSlices`. Save… remembers +//! the settings and writes the files (`file.saveForWeb`); Done only remembers them; Preview in +//! Browser opens the HTML page (`file.saveForWeb.browser`). The previews are what Save writes: +//! [`webexport::preview_image`], decoded back, cached until the art or a setting changes. + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::webexport::{ + self, SPEEDS, SliceScope, TableColor, WebFormat, WebMetadata, WebOutput, WebRender, WebSettings, download_seconds, hex, +}; +use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::quantize::{Dither, Reduction}; +use vectorcraft_render::encode::web::ColorSort; + +use super::png_options::color_button; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +pub const KIND: &str = "saveForWeb"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Save for Web".into(), + body, + confirm, + ok: Some("Save…"), + ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Save…" }), + discard: Some("Done"), + min_width: PREVIEW_W + GAP + SIDE_W, + ..DialogSpec::FORM +}; + +/// The preview area (panes and their captions) and the settings column. +const PREVIEW_W: f32 = 620.0; +const PREVIEW_H: f32 = 470.0; +const SIDE_W: f32 = 296.0; +const GAP: f32 = 16.0; +/// Label column and field widths of the settings column. +const LABEL_W: f32 = 96.0; +const FIELD_W: f32 = 190.0; +/// A pane's caption height. +const CAPTION_H: f32 = 34.0; + +/// The views (`__view`) and their tabs. +const VIEWS: [&str; 4] = ["original", "optimized", "2up", "4up"]; +const VIEW_LABELS: [&str; 4] = ["Original", "Optimized", "2-Up", "4-Up"]; +/// Zoom choices (`__zoom`: `fit` or a percentage). +const ZOOMS: [&str; 5] = ["fit", "50", "100", "200", "400"]; +const ZOOM_LABELS: [&str; 5] = ["Fit in View", "50%", "100%", "200%", "400%"]; +/// Palette sizes offered (any 2–256 can be typed). +const COLOR_COUNTS: [&str; 8] = ["2", "4", "8", "16", "32", "64", "128", "256"]; +/// Matte 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 steps: (label, quality). +const QUALITIES: [(&str, u8); 5] = [("Low", 10), ("Medium", 30), ("High", 60), ("Very High", 80), ("Maximum", 100)]; + +/// Open the dialog on the remembered settings. +pub fn open(app: &mut VectorcraftApp) -> Result { + let n = app.session.active().ok_or("no document")?.doc.artboards.len(); + let mut s = app.session.last_web_settings(); + // A remembered artboard this document doesn't have: its first. + if s.artboard.is_some_and(|a| a >= n) { + s.artboard = None; + } + let mut fields = settings_fields(&s); + let slices = app.session.active().is_some_and(|st| !st.doc.slice_layout().is_empty()); + fields.extend([ + ("__view".into(), json!("2up")), + ("__zoom".into(), json!("100")), + ("__kbps".into(), json!(56.6)), + ("__hasSlices".into(), json!(slices)), + ]); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(Value::Null) +} + +/// The settings as dialog fields. +fn settings_fields(s: &WebSettings) -> Map { + s.to_json().as_object().cloned().unwrap_or_default() +} + +/// The settings fields of the dialog (the `document.exportForWeb` params). +fn params(d: &Dialog) -> Value { + let mut p = form::params(d); + if let Some(o) = p.as_object_mut() { + o.remove("discard"); + } + p +} + +/// A `preset` field (set by an agent, or the preset list) replaces the settings with the preset's. +fn apply_preset(app: &VectorcraftApp, d: &mut Dialog) -> Result<(), String> { + let Some(name) = d.fields.remove("preset") else { return Ok(()) }; + let s = app.session.web_settings(&json!({ "preset": name }))?; + d.fields.extend(settings_fields(&s)); + Ok(()) +} + +/// The settings the fields describe. +fn settings_of(app: &VectorcraftApp, d: &Dialog) -> Result { + app.session.web_settings(¶ms(d)) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut d = d.clone(); + apply_preset(app, &mut d)?; + let p = params(&d); + // Done remembers the settings; Save… remembers them and writes the files. + app.run("webExport.settings", p.clone())?; + if d.bool("discard") { + app.ui.dialog = None; + return Ok(json!({ "remembered": true })); + } + let r = save(app, p)?; + app.ui.dialog = None; + Ok(r) +} + +/// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML +/// output, else the image); the web downloads them. +pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result { + // Bad settings never open a save panel. + let s = app.session.web_settings(&p)?; + let path = p.get("path").and_then(Value::as_str).map(str::to_string); + if path.is_none() && io::is_web(app) { + let r = app.run("document.exportForWeb", p)?; + let mut names = vec![]; + for f in r["files"].as_array().into_iter().flatten() { + let (Some(name), Some(data)) = (f["name"].as_str(), f["dataBase64"].as_str()) else { continue }; + let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; + io::write_to(&mut app.services, name, &bytes)?; + names.push(json!(name)); + } + app.status(format!("Downloaded {} file(s)", names.len())); + return Ok(json!({ "files": names, "bytes": r["bytes"] })); + } + let ext = if s.output == WebOutput::Html { "html" } else { s.format.ext() }; + let path = io::target_path(app, path, ext)?; + if let Some(o) = p.as_object_mut() { + o.insert("path".into(), json!(path)); + } + let r = app.run("document.exportForWeb", p)?; + let n = r["files"].as_array().map_or(0, Vec::len); + app.status(if n > 1 { format!("Saved {n} files beside {path}") } else { format!("Saved {path}") }); + Ok(r) +} + +/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the +/// default browser (desktop). +pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result { + if io::is_web(app) { + return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into()); + } + let stem = app.session.active().map(|st| vectorcraft_engine::cmd::fileio::file_stem(&st.doc.title)).ok_or("no document")?; + let dir = temp_folder()?; + let path = format!("{dir}/{}.html", if stem.trim().is_empty() { "Untitled" } else { stem.as_str() }); + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.insert("path".into(), json!(path)); + o.insert("output".into(), json!("html")); + } + app.run("document.exportForWeb", q)?; + app.open_url(&io::file_url(&path)); + Ok(json!({ "path": path })) +} + +/// The folder browser previews go to. +#[cfg(not(target_arch = "wasm32"))] +fn temp_folder() -> Result { + let dir = std::env::temp_dir().join("VectorCraft Save for Web"); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + Ok(dir.to_string_lossy().replace('\\', "/")) +} + +#[cfg(target_arch = "wasm32")] +fn temp_folder() -> Result { + Err("no file system here".into()) +} + +// ---------- previews ---------- + +/// What a pane shows. +#[derive(Clone)] +struct Shot { + texture: egui::TextureHandle, + size: [u32; 2], + /// The file's size (bytes); `None` for the original. + bytes: Option, + colors: Arc>, + mapped: Arc>, +} + +/// The previews of the open dialog: the rendering (until the art or the image size changes) and +/// the optimised panes by settings. +#[derive(Clone, Default)] +struct Cache { + render_key: String, + render: Option, String>>, + original: Option, + shots: Vec<(String, Result)>, +} + +fn cache_id() -> egui::Id { + egui::Id::new("save-for-web-previews") +} + +/// The texture of straight RGBA pixels. +fn texture(ctx: &egui::Context, name: &str, px: &[u8], [w, h]: [u32; 2]) -> egui::TextureHandle { + let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], px); + let opts = egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR }; + ctx.load_texture(name, img, opts) +} + +/// The rendering for `s` and the optimised image for each of `panes`, from the cache when nothing +/// they depend on changed. +fn previews(app: &VectorcraftApp, ctx: &egui::Context, s: &WebSettings, panes: &[WebSettings]) -> (Result, Vec>) { + let Some(st) = app.session.active() else { return (Err("no document".into()), vec![]) }; + let doc = &st.doc; + let mut cache: Cache = ctx.data(|m| m.get_temp(cache_id())).unwrap_or_default(); + let render_key = format!( + "{}:{}:{:?}:{:?}:{:?}:{}:{}:{:?}:{:?}", + st.uid, + st.revision, + s.width, + s.height, + s.percent, + s.anti_alias.id(), + s.clip_to_artboard, + s.artboard, + (!s.clip_to_artboard).then_some(s.slices) + ); + if cache.render_key != render_key || cache.render.is_none() { + let r = webexport::render(doc, s).map(Arc::new); + cache.original = r.as_ref().ok().map(|r| Shot { + texture: texture(ctx, "save-for-web-original", &r.rgba, [r.width, r.height]), + size: [r.width, r.height], + bytes: None, + colors: Arc::default(), + mapped: Arc::default(), + }); + cache = Cache { render_key, render: Some(r), original: cache.original, shots: vec![] }; + } + let original = match (&cache.render, &cache.original) { + (Some(Ok(_)), Some(o)) => Ok(o.clone()), + (Some(Err(e)), _) => Err(e.clone()), + _ => Err("nothing to preview".into()), + }; + let mut shots = Vec::with_capacity(panes.len()); + let mut kept = Vec::with_capacity(panes.len()); + for (i, p) in panes.iter().enumerate() { + let key = p.to_json().to_string(); + let shot = match cache.shots.iter().find(|(k, _)| *k == key) { + Some((_, s)) => s.clone(), + None => match &cache.render { + Some(Ok(r)) => optimise(ctx, doc, r, p, i), + Some(Err(e)) => Err(e.clone()), + None => Err("nothing to preview".into()), + }, + }; + kept.push((key, shot.clone())); + shots.push(shot); + } + cache.shots = kept; + ctx.data_mut(|m| m.insert_temp(cache_id(), cache)); + (original, shots) +} + +fn optimise(ctx: &egui::Context, doc: &vectorcraft_doc::Document, r: &WebRender, s: &WebSettings, pane: usize) -> Result { + let o = webexport::preview_image(doc, &r.rgba, r.width, r.height, s)?; + let (px, w, h) = webexport::decode(&o)?; + Ok(Shot { + texture: texture(ctx, &format!("save-for-web-{pane}"), &px, [w, h]), + size: [w, h], + bytes: Some(o.bytes.len()), + colors: Arc::new(o.colors), + mapped: Arc::new(o.mapped), + }) +} + +/// The two lighter settings 4-Up offers beside the current ones: lower quality, fewer colours, or +/// for PNG-24 a palette and a JPEG. +fn alternates(s: &WebSettings) -> [WebSettings; 2] { + let with = |f: &dyn Fn(&mut WebSettings)| { + let mut a = s.clone(); + f(&mut a); + a + }; + match s.format { + WebFormat::Jpg => [with(&|a| a.quality /= 2), with(&|a| a.quality /= 4)], + WebFormat::Gif | WebFormat::Png8 => [with(&|a| a.colors = (a.colors / 2).max(2)), with(&|a| a.colors = (a.colors / 4).max(2))], + WebFormat::Png24 => [ + with(&|a| { + a.format = WebFormat::Png8; + a.colors = 256; + }), + with(&|a| a.format = WebFormat::Jpg), + ], + } +} + +// ---------- the dialog ---------- + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + if app.session.active().is_none() { + return true; + } + if let Err(e) = apply_preset(app, d) { + app.status(e); + } + let settings = settings_of(app, d); + let mut browser = false; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(PREVIEW_W); + browser = preview_area(app, ui, d, &settings); + }); + ui.add_space(GAP); + ui.vertical(|ui| { + ui.set_width(SIDE_W); + side(app, ui, d, settings.as_ref().ok()); + }); + }); + if browser && let Err(e) = app.run("file.saveForWeb.browser", params(d)) { + app.status(e); + } + false +} + +/// The view tabs, the panes and the zoom and speed row. Returns whether Preview in Browser was +/// clicked. +fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, settings: &Result) -> bool { + let t = Tokens::get(ui.ctx()); + let view = VIEWS.iter().position(|v| *v == d.str("__view")).unwrap_or(2); + if let Some(i) = widgets::tab_bar(ui, &VIEW_LABELS, view) { + d.fields.insert("__view".into(), json!(VIEWS[i])); + } + ui.add_space(4.0); + let (area, resp) = ui.allocate_exact_size(egui::vec2(PREVIEW_W, PREVIEW_H), egui::Sense::click_and_drag()); + let pan_id = egui::Id::new("save-for-web-pan"); + let mut pan: egui::Vec2 = ui.ctx().data(|m| m.get_temp(pan_id)).unwrap_or_default(); + if resp.dragged() { + pan += resp.drag_delta(); + ui.ctx().data_mut(|m| m.insert_temp(pan_id, pan)); + } + match settings { + Err(e) => { + ui.painter().rect_filled(area, 2.0, t.pasteboard); + ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, format!("⚠ {e}"), egui::FontId::proportional(12.5), t.text); + } + Ok(s) => { + let alts = alternates(s); + let panes: Vec = if view == 3 { vec![s.clone(), alts[0].clone(), alts[1].clone()] } else { vec![s.clone()] }; + // The current settings' image is made in every view: the colour table shows its colours. + let (original, shots) = previews(app, ui.ctx(), s, &panes); + let zoom = d.str("__zoom").parse::().ok().filter(|z| z.is_finite() && *z > 0.0).map(|z| z / 100.0); + let kbps = d.f64("__kbps", 56.6); + let Some(checker) = app.session.active().map(|st| crate::canvas::checker_texture(ui.ctx(), &st.doc.setup)) else { return false }; + let cells: Vec<(egui::Rect, Pane)> = match view { + 0 => vec![(area, Pane::Original)], + 1 => vec![(area, Pane::Optimized(0))], + 2 => split(area, 2, 1).into_iter().zip([Pane::Original, Pane::Optimized(0)]).collect(), + _ => split(area, 2, 2).into_iter().zip([Pane::Original, Pane::Optimized(0), Pane::Optimized(1), Pane::Optimized(2)]).collect(), + }; + for (rect, pane) in cells { + let (shot, settings) = match pane { + Pane::Original => (&original, None), + Pane::Optimized(i) => (shots.get(i).unwrap_or(&original), panes.get(i)), + }; + let selected = view == 3 && matches!(pane, Pane::Optimized(0)); + draw_pane(ui, rect, shot, settings, zoom, pan, (checker.0.id(), checker.1), selected, kbps); + // 4-Up: clicking a lighter alternate takes its settings. + if let (Pane::Optimized(1..), Some(a)) = (pane, settings) + && resp.clicked() + && resp.interact_pointer_pos().is_some_and(|p| rect.contains(p)) + { + d.fields.extend(settings_fields(a)); + } + } + } + } + ui.add_space(6.0); + let mut browser = false; + ui.horizontal(|ui| { + let z = ZOOMS.iter().position(|z| *z == d.str("__zoom")).unwrap_or(2); + if let Some(i) = widgets::dropdown(ui, "sfw-zoom", ZOOM_LABELS[z], &ZOOM_LABELS, 110.0) { + d.fields.insert("__zoom".into(), json!(ZOOMS[i])); + ui.ctx().data_mut(|m| m.insert_temp(pan_id, egui::Vec2::ZERO)); + } + let kbps = d.f64("__kbps", 56.6); + let labels: Vec = SPEEDS.iter().map(|s| speed_label(*s)).collect(); + let names: Vec<&str> = labels.iter().map(String::as_str).collect(); + if let Some(i) = widgets::dropdown(ui, "sfw-speed", &speed_label(kbps), &names, 110.0) + && let Some(s) = SPEEDS.get(i) + { + d.fields.insert("__kbps".into(), json!(s)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add_enabled(!io::is_web(app), |ui: &mut egui::Ui| widgets::secondary_button(ui, "Preview in Browser")); + browser = b.on_disabled_hover_text("Needs the desktop app").clicked(); + }); + }); + browser +} + +#[derive(Clone, Copy)] +enum Pane { + Original, + /// The optimised image of the current settings (0) or an alternate (1, 2). + Optimized(usize), +} + +/// `area` cut into `cols` × `rows` panes with a gap. +fn split(area: egui::Rect, cols: usize, rows: usize) -> Vec { + let gap = 6.0; + let w = (area.width() - gap * (cols as f32 - 1.0)) / cols as f32; + let h = (area.height() - gap * (rows as f32 - 1.0)) / rows as f32; + (0..rows) + .flat_map(|r| (0..cols).map(move |c| (c, r))) + .map(|(c, r)| egui::Rect::from_min_size(area.min + egui::vec2(c as f32 * (w + gap), r as f32 * (h + gap)), egui::vec2(w, h))) + .collect() +} + +/// `kbps` as the speed list shows it. +fn speed_label(kbps: f64) -> String { + if kbps >= 1024.0 { format!("{} Mbps", kbps / 1024.0) } else { format!("{kbps} Kbps") } +} + +/// A file size as the captions show it. +fn size_label(bytes: usize) -> String { + if bytes < 1024 { + format!("{bytes} bytes") + } else if bytes < 1 << 20 { + format!("{:.1}K", bytes as f64 / 1024.0) + } else { + format!("{:.2}M", bytes as f64 / f64::from(1 << 20)) + } +} + +/// One pane: the image over the transparency grid at `zoom` (`None`: fit), moved by `pan`, and +/// its caption: what it is, its size and download time, and its colours or quality. +#[allow(clippy::too_many_arguments)] +fn draw_pane( + ui: &egui::Ui, + rect: egui::Rect, + shot: &Result, + settings: Option<&WebSettings>, + zoom: Option, + pan: egui::Vec2, + (checker, cell): (egui::TextureId, f32), + selected: bool, + kbps: f64, +) { + let t = Tokens::get(ui.ctx()); + let image = egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, rect.max.y - CAPTION_H)); + let p = ui.painter().with_clip_rect(image); + p.rect_filled(image, 0.0, t.pasteboard); + let font = egui::FontId::proportional(11.5); + let (first, second, third) = match shot { + Err(e) => { + p.text(image.center(), egui::Align2::CENTER_CENTER, format!("⚠ {e}"), font.clone(), t.text); + ("".into(), String::new(), String::new()) + } + Ok(shot) => { + let size = egui::vec2(shot.size[0] as f32, shot.size[1] as f32); + let scale = zoom.unwrap_or_else(|| (image.width() / size.x).min(image.height() / size.y).min(1.0)); + let shown = egui::Rect::from_center_size(image.center() + pan, size * scale); + let uv = egui::Rect::from_min_max(egui::Pos2::ZERO, (shown.size() / (2.0 * cell)).to_pos2()); + p.image(checker, shown, uv, egui::Color32::WHITE); + p.image(shot.texture.id(), shown, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + match (settings, shot.bytes) { + (Some(s), Some(bytes)) => { + let secs = download_seconds(bytes, kbps); + let time = if secs < 1.0 { "< 1 sec".into() } else { format!("{} sec", secs.ceil()) }; + let detail = match s.format { + WebFormat::Jpg => format!("Quality {}", s.quality), + WebFormat::Png24 => String::new(), + _ => format!("{} colors", shot.colors.len()), + }; + (s.format.label().to_string(), format!("{} {time} @ {}", size_label(bytes), speed_label(kbps)), detail) + } + _ => ("Original".into(), format!("{} × {} px", shot.size[0], shot.size[1]), String::new()), + } + } + }; + let stroke = if selected { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.border) }; + ui.painter().rect_stroke(image, 0.0, stroke, egui::StrokeKind::Inside); + let caption = egui::Rect::from_min_max(egui::pos2(rect.min.x, image.max.y), rect.max).shrink2(egui::vec2(4.0, 3.0)); + let p = ui.painter().with_clip_rect(caption); + p.text(caption.left_top(), egui::Align2::LEFT_TOP, first, font.clone(), t.text_strong); + p.text(caption.left_bottom(), egui::Align2::LEFT_BOTTOM, second, font.clone(), t.text_dim); + p.text(caption.right_bottom(), egui::Align2::RIGHT_BOTTOM, third, font, t.text_dim); +} + +// ---------- the settings column ---------- + +/// `(value, label)` pairs of a keyed enum. +fn pairs(all: &[T], key: fn(T) -> &'static str, label: fn(T) -> &'static str) -> Vec<(&'static str, &'static str)> { + all.iter().map(|v| (key(*v), label(*v))).collect() +} + +fn side(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) { + presets_row(app, ui, d, s); + widgets::divider(ui); + egui::ScrollArea::vertical().id_salt("sfw-settings").max_height(PREVIEW_H - 40.0).auto_shrink([false, true]).show(ui, |ui| { + let choice = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]| { + form::choice(ui, d, key, label, (LABEL_W, FIELD_W), options); + }; + choice(ui, d, "format", "Format:", &pairs(&WebFormat::ALL, WebFormat::id, WebFormat::label)); + let format = s.map_or(WebFormat::Gif, |s| s.format); + match format { + WebFormat::Gif | WebFormat::Png8 => { + choice(ui, d, "reduction", "Reduction:", &pairs(&Reduction::ALL, Reduction::id, Reduction::label)); + colors_row(ui, d); + choice(ui, d, "dither", "Dither:", &pairs(&Dither::ALL, Dither::id, Dither::label)); + if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) { + slider_row(ui, d, "ditherAmount", "Amount:", "%"); + } + check_pair(ui, d, ("transparency", "Transparency"), ("interlaced", "Interlaced")); + matte_row(ui, d); + slider_row(ui, d, "webSnap", "Web Snap:", "%"); + if format == WebFormat::Gif { + slider_row(ui, d, "lossy", "Lossy:", "%"); + } + } + WebFormat::Jpg => { + quality_row(ui, d); + check_pair(ui, d, ("progressive", "Progressive"), ("optimized", "Optimized")); + widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "embedProfile", "ICC Profile")); + matte_row(ui, d); + } + WebFormat::Png24 => { + check_pair(ui, d, ("transparency", "Transparency"), ("interlaced", "Interlaced")); + matte_row(ui, d); + } + } + widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "convertToSrgb", "Convert to sRGB")); + choice(ui, d, "metadata", "Metadata:", &pairs(&WebMetadata::ALL, WebMetadata::key, WebMetadata::label)); + if format.palette() { + widgets::divider(ui); + color_table(ui, d, s); + } + widgets::divider(ui); + image_size(app, ui, d, s); + widgets::divider(ui); + if d.bool("__hasSlices") { + choice(ui, d, "slices", "Slices:", &pairs(&SliceScope::ALL, SliceScope::key, SliceScope::label)); + } + choice(ui, d, "output", "Output:", &pairs(&WebOutput::ALL, WebOutput::key, WebOutput::label)); + }); +} + +/// The preset list (the settings' preset, else "[Unnamed]") with Save and Delete. +fn presets_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) { + let presets = app.session.web_presets(); + let saved = app.session.prefs.web_export_presets.len(); + let current = s.and_then(|s| presets.iter().rposition(|p| p.settings == *s)); + let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + let mut run: Option<(&str, Value)> = None; + widgets::label_row(ui, "Preset:", LABEL_W, |ui| { + let shown = current.and_then(|i| names.get(i).copied()).unwrap_or("[Unnamed]"); + if let Some(i) = widgets::dropdown(ui, "sfw-preset", shown, &names, FIELD_W - 60.0) + && let Some(p) = presets.get(i) + { + d.fields.extend(settings_fields(&p.settings)); + } + let editing = d.fields.contains_key("__presetName"); + if widgets::icon_button(ui, "save", "Save the settings as a preset", editing, 24.0).clicked() { + match editing { + true => _ = d.fields.remove("__presetName"), + false => _ = d.fields.insert("__presetName".into(), json!("")), + } + } + let user = current.filter(|i| *i + saved >= presets.len()); + let del = ui.add_enabled(user.is_some(), |ui: &mut egui::Ui| widgets::icon_button(ui, "trash-2", "Delete this saved preset", false, 24.0)); + if del.clicked() + && let Some(name) = user.and_then(|i| names.get(i)) + { + run = Some(("webExport.presets.delete", json!({ "name": name }))); + } + }); + if d.fields.contains_key("__presetName") { + widgets::label_row(ui, "Name:", LABEL_W, |ui| { + form::text(ui, d, "__presetName", FIELD_W - 70.0); + let name = d.str("__presetName"); + if ui.add_enabled(!name.trim().is_empty(), |ui: &mut egui::Ui| widgets::flat_button(ui, "Save", 52.0)).clicked() { + let mut p = params(d); + p["name"] = json!(name.trim()); + run = Some(("webExport.presets.save", p)); + } + }); + } + if let Some((id, p)) = run { + match app.run(id, p) { + Ok(_) => _ = d.fields.remove("__presetName"), + Err(e) => app.status(e), + } + } +} + +/// Two checkboxes side by side. +fn check_pair(ui: &mut egui::Ui, d: &mut Dialog, (a, a_label): (&str, &str), (b, b_label): (&str, &str)) { + widgets::label_row(ui, "", LABEL_W, |ui| { + ui.allocate_ui(egui::vec2(FIELD_W / 2.0, 20.0), |ui| { + ui.set_min_width(FIELD_W / 2.0); + form::check(ui, d, a, a_label); + }); + form::check(ui, d, b, b_label); + }); +} + +/// A 0–100 slider bound to `d.fields[key]`, its value shown with `suffix`. +fn slider_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, suffix: &str) { + widgets::label_row(ui, label, LABEL_W, |ui| { + let mut v = d.f64(key, 0.0).round().clamp(0.0, 100.0) as u8; + ui.spacing_mut().slider_width = FIELD_W - 60.0; + if ui.add(egui::Slider::new(&mut v, 0..=100).suffix(suffix)).changed() { + d.fields.insert(key.into(), json!(v)); + } + }); +} + +/// The palette size: the usual counts, or any 2–256. +fn colors_row(ui: &mut egui::Ui, d: &mut Dialog) { + widgets::label_row(ui, "Colors:", LABEL_W, |ui| { + let mut n = d.f64("colors", 128.0).round().clamp(2.0, 256.0) as u16; + if let Some(c) = widgets::dropdown(ui, "sfw-colors", &n.to_string(), &COLOR_COUNTS, 80.0).and_then(|i| COLOR_COUNTS.get(i)) { + n = c.parse().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)); + } + }); +} + +/// JPEG quality: a named step, then the exact value. +fn quality_row(ui: &mut egui::Ui, d: &mut Dialog) { + let q = d.f64("quality", 60.0).round().clamp(0.0, 100.0) as u8; + let step = QUALITIES.iter().rev().find(|(_, v)| q >= *v).map_or("Low", |(l, _)| *l); + widgets::label_row(ui, "Quality:", LABEL_W, |ui| { + let labels = QUALITIES.map(|(l, _)| l); + if let Some((_, v)) = widgets::dropdown(ui, "sfw-quality", step, &labels, FIELD_W).and_then(|i| QUALITIES.get(i)) { + d.fields.insert("quality".into(), json!(v)); + } + }); + slider_row(ui, d, "quality", "", ""); +} + +/// The matte: none, white, black or a colour. +fn matte_row(ui: &mut egui::Ui, d: &mut Dialog) { + let matte = d.str("matte"); + let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3); + widgets::label_row(ui, "Matte:", LABEL_W, |ui| { + if let Some(i) = widgets::dropdown(ui, "sfw-matte", MATTE_LABELS[at], &MATTE_LABELS, 120.0) { + d.fields.insert("matte".into(), json!(MATTES.get(i).copied().unwrap_or("#808080"))); + } + if at == 3 { + color_button(ui, d, "matte"); + } + }); +} + +/// The colour table of the current settings' preview: swatches (the selected one ringed; locked +/// ones with a square, web-safe ones with a diamond, transparent ones over the grid) and the +/// buttons that edit the selected colour, plus the sort order. +fn color_table(ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) { + let t = Tokens::get(ui.ctx()); + let cache: Option = ui.ctx().data(|m| m.get_temp(cache_id())); + let key = s.map(|s| s.to_json().to_string()); + let shot = cache.and_then(|c| c.shots.into_iter().find(|(k, _)| Some(k) == key.as_ref())).and_then(|(_, s)| s.ok()); + let Some(shot) = shot else { + widgets::subheader(ui, "Color Table"); + widgets::dim_label(ui, "Shown with the optimised preview."); + return; + }; + widgets::subheader(ui, &format!("Color Table ({} colors)", shot.colors.len())); + let selected = d.str("__color"); + // (colour shown, source, transparent, locked, web-safe, mapped by hand) + let rows: Vec<(TableColor, bool)> = shot + .colors + .iter() + .map(|c| (*c, false)) + .chain(shot.mapped.iter().map(|c| (TableColor { color: *c, source: *c, transparent: true, locked: false, web_shifted: false }, true))) + .collect(); + const CELL: f32 = 15.0; + let per_row = ((SIDE_W - 8.0) / (CELL + 2.0)).floor().max(1.0) as usize; + let lines = rows.len().div_ceil(per_row).max(1); + let (area, resp) = ui.allocate_exact_size(egui::vec2(SIDE_W - 8.0, lines as f32 * (CELL + 2.0)), egui::Sense::click()); + let p = ui.painter(); + for (i, (c, mapped)) in rows.iter().enumerate() { + let r = egui::Rect::from_min_size( + area.min + egui::vec2((i % per_row) as f32 * (CELL + 2.0), (i / per_row) as f32 * (CELL + 2.0)), + egui::vec2(CELL, CELL), + ); + let [red, g, b] = c.color; + let fill = egui::Color32::from_rgb(red, g, b); + if c.transparent { + // Half the colour (a mapped one), half the grid. + p.rect_filled(r, 0.0, egui::Color32::WHITE); + p.rect_filled(egui::Rect::from_min_size(r.min, r.size() / 2.0), 0.0, egui::Color32::from_gray(200)); + p.rect_filled(egui::Rect::from_min_size(r.center(), r.size() / 2.0), 0.0, egui::Color32::from_gray(200)); + if *mapped { + p.add(egui::Shape::convex_polygon(vec![r.left_top(), r.right_top(), r.left_bottom()], fill, egui::Stroke::NONE)); + } + } else { + p.rect_filled(r, 0.0, fill); + } + let mark = if c.color.iter().map(|v| u32::from(*v)).sum::() > 380 { egui::Color32::BLACK } else { egui::Color32::WHITE }; + if c.locked { + p.rect_filled(egui::Rect::from_min_size(r.right_bottom() - egui::vec2(5.0, 5.0), egui::vec2(4.0, 4.0)), 0.0, mark); + } + if !c.transparent && vectorcraft_render::encode::quantize::web_safe(c.color) == c.color { + let m = r.center(); + p.add(egui::Shape::convex_polygon( + vec![m + egui::vec2(0.0, -2.5), m + egui::vec2(2.5, 0.0), m + egui::vec2(0.0, 2.5), m + egui::vec2(-2.5, 0.0)], + mark, + egui::Stroke::NONE, + )); + } + // The transparent entry itself isn't a colour to edit. + let pickable = !c.transparent || *mapped; + let picked = pickable && hex(c.source) == selected; + p.rect_stroke(r, 0.0, egui::Stroke::new(if picked { 2.0 } else { 1.0 }, if picked { t.accent } else { t.border }), egui::StrokeKind::Outside); + if pickable && resp.clicked() && resp.interact_pointer_pos().is_some_and(|pos| r.contains(pos)) { + d.fields.insert("__color".into(), json!(hex(c.source))); + } + } + ui.add_space(4.0); + let table = s.map(|s| s.color_table.clone()).unwrap_or_default(); + let on = |list: &[String]| list.iter().any(|h| h.eq_ignore_ascii_case(&selected)); + ui.horizontal(|ui| { + ui.add_enabled_ui(!selected.is_empty(), |ui| { + for (list, on_label, off_label) in + [("transparent", "Restore", "Transparent"), ("webShift", "Unshift", "Web Shift"), ("locked", "Unlock", "Lock")] + { + let current = match list { + "transparent" => &table.transparent, + "webShift" => &table.web_shift, + _ => &table.locked, + }; + let is_on = on(current); + if widgets::flat_button(ui, if is_on { on_label } else { off_label }, 88.0).clicked() { + toggle(d, list, &selected, !is_on); + } + } + }); + }); + let sort = table.sort; + widgets::label_row(ui, "Order:", LABEL_W, |ui| { + let labels = ColorSort::ALL.map(ColorSort::label); + if let Some(i) = widgets::dropdown(ui, "sfw-sort", sort.label(), &labels, FIELD_W) + && let Some(o) = ColorSort::ALL.get(i) + { + set_table(d, "sort", json!(o.id())); + } + }); +} + +/// Add `color` to the colour table's `list`, or take it out. +pub(super) fn toggle(d: &mut Dialog, list: &str, color: &str, add: bool) { + let mut v: Vec = d.fields.get("colorTable").and_then(|t| t.get(list)).and_then(Value::as_array).cloned().unwrap_or_default(); + v.retain(|c| !c.as_str().is_some_and(|c| c.eq_ignore_ascii_case(color))); + if add { + v.push(json!(color.to_ascii_lowercase())); + } + set_table(d, list, Value::Array(v)); +} + +/// Set one key of the `colorTable` field. +pub(super) fn set_table(d: &mut Dialog, key: &str, value: Value) { + let table = d.fields.entry("colorTable").or_insert_with(|| json!({})); + if !table.is_object() { + *table = json!({}); + } + table[key] = value; +} + +/// Image Size: width and height in pixels (proportional) or a percentage, anti-aliasing, Clip to +/// Artboard and which artboard. +fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) { + widgets::subheader(ui, "Image Size"); + let Some(st) = app.session.active() else { return }; + let region = s.and_then(|s| webexport::region(&st.doc, s).ok()); + let (w, h) = region.map_or((0, 0), |(r, scale)| vectorcraft_render::region_pixels(r, scale)); + let set = |d: &mut Dialog, key: &str, v: Value| { + for k in ["width", "height", "percent"] { + d.fields.insert(k.into(), Value::Null); + } + d.fields.insert(key.into(), v); + }; + widgets::label_row(ui, "W:", LABEL_W, |ui| { + if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) { + set(d, "width", json!(v.round().max(1.0))); + } + ui.label("H:"); + if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) { + set(d, "height", json!(v.round().max(1.0))); + } + }); + let percent = region.map_or(100.0, |(_, scale)| (scale * 100.0 * 100.0).round() / 100.0); + widgets::label_row(ui, "Percent:", LABEL_W, |ui| { + if let Some(v) = widgets::plain_field(ui, "sfw-pct", percent, "%", 2, 80.0) { + set(d, "percent", json!(v)); + } + }); + form::choice(ui, d, "antiAlias", "Anti-aliasing:", (LABEL_W, FIELD_W), &pairs(&AntiAlias::ALL, AntiAlias::id, AntiAlias::label)); + widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "clipToArtboard", "Clip to Artboard")); + let boards = &st.doc.artboards; + if boards.len() > 1 && d.bool("clipToArtboard") { + let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect(); + let at = s.and_then(|s| s.artboard).unwrap_or(0).min(names.len() - 1); + widgets::label_row(ui, "Artboard:", LABEL_W, |ui| { + if let Some(i) = widgets::dropdown(ui, "sfw-artboard", names.get(at).copied().unwrap_or_default(), &names, FIELD_W) { + d.fields.insert("artboard".into(), json!(i)); + } + }); + } +} diff --git a/crates/ui-egui/src/dialogs/tests_save_for_web.rs b/crates/ui-egui/src/dialogs/tests_save_for_web.rs new file mode 100644 index 00000000..164087a9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_save_for_web.rs @@ -0,0 +1,151 @@ +//! Save for Web (Legacy), drawn headlessly and driven as an agent drives it. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; + +use super::tests_export::{frame, kind, set}; +use super::*; +use crate::Services; + +type Log = Rc>>; + +/// An app with a 60×40 pt document (an ellipse), whose save panel answers inside `dir`; `web` +/// downloads instead (into the log). +fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) { + let log = Log::default(); + let (l1, l2) = (log.clone(), log.clone()); + let dir = dir.to_string_lossy().replace('\\', "/"); + let mut services = Services { + pick_save: Some(Box::new(move |p: &crate::FilePick| Some(format!("{dir}/{}", p.name)))), + open_url: Some(Box::new(move |u: &str| l1.borrow_mut().push(format!("open {u}")))), + ..Default::default() + }; + if web { + services.download = Some(Box::new(move |name: &str, b: &[u8]| l2.borrow_mut().push(format!("download {name} {}", b.len())))); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 60, "height": 40})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap(); + app.run("paint.setFill", json!({"color": "#cc3300"})).unwrap(); + (app, log) +} + +#[test] +fn every_view_and_format_draws() { + let dir = vectorcraft_testkit::temp_dir("sfw-views"); + let (mut app, _) = app(&dir, false); + assert_eq!(crate::menus::shortcut_of("file.saveForWeb"), Some("Cmd+Alt+Shift+S")); + assert!(crate::menus::enabled(&app, "file.saveForWeb")); + assert_eq!((spec(save_for_web::KIND).ok_label.unwrap())(&app), "Save…"); + app.run("file.saveForWeb", json!({})).unwrap(); + assert_eq!(kind(&app), Some(save_for_web::KIND)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("format"), d.str("__view"), d.f64("colors", 0.0)), ("gif".into(), "2up".into(), 128.0)); + for view in ["original", "optimized", "2up", "4up"] { + set(&mut app, "__view", json!(view)); + for format in ["gif", "jpg", "png8", "png24"] { + set(&mut app, "format", json!(format)); + frame(&mut app); + assert_eq!(kind(&app), Some(save_for_web::KIND), "{view} {format}"); + } + } + // Fit, other speeds, a colour selected in the table, a bad value (an error in the panes). + set(&mut app, "format", json!("gif")); + set(&mut app, "__zoom", json!("fit")); + set(&mut app, "__kbps", json!(1024)); + set(&mut app, "__color", json!("#cc3300")); + frame(&mut app); + set(&mut app, "percent", json!(0)); + frame(&mut app); + assert_eq!(kind(&app), Some(save_for_web::KIND)); +} + +#[test] +fn save_remembers_and_writes_and_done_only_remembers() { + let dir = vectorcraft_testkit::temp_dir("sfw-save"); + let (mut app, _) = app(&dir, false); + app.run("file.saveForWeb", json!({})).unwrap(); + // A preset field loads the preset; the keys set after it win. + set(&mut app, "preset", json!("JPEG, quality 50")); + frame(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("format"), d.f64("quality", 0.0)), ("jpg".into(), 50.0)); + assert!(!d.fields.contains_key("preset")); + set(&mut app, "quality", json!(70)); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let path = dir.join("Untitled-1.jpg"); + assert_eq!(r["path"], json!(path.to_string_lossy().replace('\\', "/"))); + assert!(std::fs::read(&path).unwrap().starts_with(&[0xFF, 0xD8])); + assert_eq!(app.session.last_web_settings().quality, 70, "remembered"); + // It reopens on them; Done remembers without writing. + app.run("file.saveForWeb", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("quality", 0.0), 70.0); + set(&mut app, "format", json!("png24")); + set(&mut app, "discard", json!(true)); + assert_eq!(confirm(&mut app).unwrap()["remembered"], true); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.last_web_settings().format, vectorcraft_engine::cmd::webexport::WebFormat::Png24); + assert!(!dir.join("Untitled-1.png").exists()); + // Bad settings keep the dialog open, with nothing written. + app.run("file.saveForWeb", json!({})).unwrap(); + set(&mut app, "colorTable", json!({"locked": ["nope"]})); + assert!(confirm(&mut app).is_err()); + assert!(!dir.join("Untitled-1.png").exists()); +} + +#[test] +fn html_output_with_slices_saves_the_page_and_images() { + let dir = vectorcraft_testkit::temp_dir("sfw-html"); + let (mut app, _) = app(&dir, false); + app.run("object.slice.create", json!({"x": 0, "y": 0, "width": 30, "height": 20})).unwrap(); + app.run("file.saveForWeb", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("__hasSlices")); + set(&mut app, "output", json!("html")); + frame(&mut app); + let r = confirm(&mut app).unwrap(); + let n = app.session.execute("slice.list", &json!({})).unwrap()["slices"].as_array().unwrap().len(); + assert_eq!(r["files"].as_array().unwrap().len(), n + 1); + assert!(std::fs::read_to_string(dir.join("Untitled-1.html")).unwrap().contains("images/Untitled-1_01.gif")); + assert!(dir.join("images/Untitled-1_01.gif").exists()); +} + +#[test] +fn the_web_downloads_and_has_no_browser_preview() { + let dir = vectorcraft_testkit::temp_dir("sfw-web"); + let (mut app, log) = app(&dir, true); + assert_eq!((spec(save_for_web::KIND).ok_label.unwrap())(&app), "Download"); + let r = app.run("file.saveForWeb", json!({"format": "png8", "output": "html"})).unwrap(); + assert_eq!(r["files"], json!(["Untitled-1.html", "images/Untitled-1.png"])); + let log = log.borrow().clone(); + assert!(log[0].starts_with("download Untitled-1.html ") && log[1].starts_with("download Untitled-1.png "), "{log:?}"); + assert!(app.run("file.saveForWeb.browser", json!({})).is_err()); +} + +#[test] +fn preview_in_browser_opens_the_page() { + let dir = vectorcraft_testkit::temp_dir("sfw-browser"); + let (mut app, log) = app(&dir, false); + let r = app.run("file.saveForWeb.browser", json!({"format": "jpg"})).unwrap(); + let page = r["path"].as_str().unwrap().to_string(); + assert!(page.ends_with("/Untitled-1.html")); + assert!(std::fs::read_to_string(&page).unwrap().contains("images/Untitled-1.jpg")); + let log = log.borrow(); + assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}"); +} + +#[test] +fn colour_table_buttons_edit_the_lists() { + let mut d = Dialog::new(save_for_web::KIND, json!({})); + save_for_web::toggle(&mut d, "locked", "#cc3300", true); + save_for_web::toggle(&mut d, "webShift", "#123456", true); + save_for_web::toggle(&mut d, "locked", "#CC3300", true); + assert_eq!(d.fields["colorTable"], json!({"locked": ["#cc3300"], "webShift": ["#123456"]})); + save_for_web::toggle(&mut d, "locked", "#cc3300", false); + save_for_web::set_table(&mut d, "sort", json!("hue")); + assert_eq!(d.fields["colorTable"], json!({"locked": [], "webShift": ["#123456"], "sort": "hue"})); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 7b24eba1..5dcc0c8d 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -478,7 +478,7 @@ pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result String { +pub(crate) fn file_url(path: &str) -> String { let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()); let abs = abs.replace('\\', "/"); let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" }); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5960954b..25a6ff7f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -473,6 +473,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", ), + ( + "file.saveForWeb", + "Save for Web (Legacy)…", + "Cmd+Alt+Shift+S", + "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", + ), + ( + "file.saveForWeb.browser", + "Preview in Browser", + "", + "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -855,6 +867,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::save_for_web::open(app), + "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), + "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), _ => return None, }; Some(r) @@ -1089,6 +1105,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.placementOptionsDialog" => selected_image(app, |_| true), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.epsOptionsDialog" => app.session.active().is_some(), + "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), _ => true, } } @@ -1149,7 +1166,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Export As…", "file.exportAs"), c("Export As SVG…", "file.export.svg"), c("Export As PNG…", "file.export.png"), - todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S"), + c("Save for Web (Legacy)…", "file.saveForWeb"), ], ), c("Export Selection…", "document.exportSelection"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 47904e46..eee5bef4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -374,3 +374,12 @@ the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster r (16, 24, 32). The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped rows off; RLE turns flipped rows off). `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with `document.export`. + +Save for Web: File → Export → Save for Web (Legacy)… (`file.saveForWeb`, Ctrl/Cmd+Alt+Shift+S) opens the `saveForWeb` +dialog on the remembered settings (`webExport.settings`). Its fields are the `document.exportForWeb` settings +(`format`, `colors`, `reduction`, `dither`, `quality`, `colorTable`, `width`, `percent`, `output`…; set `preset` to load +a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit` or a percentage), `__kbps` and +`__color` (the colour table's selected colour). `ui.dialog.confirm` remembers the settings and saves (a save panel for +the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers +the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser` +writes the HTML page to a temporary folder and opens it in the default browser (desktop). diff --git a/docs/mcp.md b/docs/mcp.md index 2719366c..9f1daee1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1382,3 +1382,33 @@ on white. TIFF and BMP files open again; `document.formats` lists every option. {"name":"run_command","arguments":{"command":"document.export","params":{"format":"bmp","depth":8,"rle":true}}} {"name":"export","arguments":{"path":"/tmp/sprite.tga","options":{"depth":32}}} ``` + +## Save for Web + +File → Export → Save for Web (Legacy) writes optimised web images: `document.exportForWeb {format: gif|jpg|png8|png24, +…}` renders the artboard (`clipToArtboard`, the default; `artboard` picks one) or the visible art and the slices, at +`width`, `height` or `percent`, and encodes it with the format's settings: for GIF and PNG-8 `reduction`, `colors`, +`dither`, `ditherAmount`, `transparency`, `matte`, `interlaced`, `webSnap`, `lossy` (GIF) and `colorTable {locked, +transparent, webShift, sort}`; for JPEG `quality`, `progressive`, `optimized`, `embedProfile` and `matte`; for PNG-24 +`transparency`, `matte` and `interlaced`. `metadata` (none, copyright, contact, all) writes File Info as PNG text or a +GIF/JPEG comment; `convertToSrgb` marks PNGs as sRGB. With slices (`slice.list`) each image slice becomes +`images/.` (`slices: selected` writes the selected ones, `none` one image); `output: html` adds +`.html` placing them, with each slice's URL, target, alt text and status message, and No Image / HTML Text cells. +Without `path` the files come back as `{name, dataBase64}`. + +`document.exportForWeb.preview` returns what Save writes for the same settings without writing it: `bytes` (the exact +file size), `seconds` to download at `kbps` (default 56.6), the size, and for palette formats the colour table +(`colors: [{color, source, transparent, locked, webShifted, webSafe}]`). The colour table's lists name colours by +`source` (the colour the reduction made), so a colour keeps its edits when it is web-shifted or snapped. `slice: n` +previews one slice; `image: true` adds the file. + +Presets: `webExport.presets.list` (built-in ones, then saved ones), `webExport.presets.save {name, newName?, …settings}`, +`webExport.presets.delete {name}`; any name works as `preset` in the other commands (its settings, then the keys +given). `webExport.settings {…}` remembers the settings the dialog opens on (`{}` reads them, `reset: true` restores the +defaults); presets and remembered settings are preferences. + +```json +{"name":"run_command","arguments":{"command":"document.exportForWeb.preview","params":{"format":"gif","colors":32,"dither":"none"}}} +{"name":"run_command","arguments":{"command":"document.exportForWeb","params":{"path":"/tmp/web/page.html","format":"png8","output":"html"}}} +{"name":"run_command","arguments":{"command":"webExport.presets.save","params":{"name":"Banner","format":"jpg","quality":70}}} +```