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}}} +```