//! File › Export › Save for Web (Legacy), File › Export › Export Preferences, the Quick Export //! pipeline that honours them, and File › Generate › Image Assets (Photoshop's Generator). //! //! Save for Web optimises the flattened (optionally resized, sRGB-converted) image as GIF, //! PNG-8, PNG-24, JPEG or WBMP, per slice, into a folder with an optional HTML table; without an //! output it only reports the optimised size (the dialog's 2-Up/4-Up annotations). Colour //! reduction and dithering reuse `algo::quantize`; GIF/WBMP/progressive JPEG writers live in //! `codecs::web`. //! //! Image Assets implements the naming grammar of Adobe's public Generator documentation: a //! layer or group named `foo.png`, `200% foo@2x.png, 48x48 icons/foo.png8`, `photo.jpg80%`, //! `?x100 thumb.gif` is exported into `-assets/` whenever the document is saved while //! the feature is on; a `default` layer (`default 50% low/ + 200% @2x`) adds variants. use photocraft_algo::quantize::{self, Dither, Forced, PaletteKind}; use photocraft_doc::slices::{self, ResolvedSlice, SliceKind}; use photocraft_doc::{DocId, Document, LayerContent, LayerId}; use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::file_cmds::{is_windows_device_name, join, native_doc, portable_device_name, stem, write_file}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } fn other(e: impl std::fmt::Display) -> EngineError { EngineError::Other(e.to_string()) } // ---------- settings ---------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WebFormat { Gif, Png8, Png24, Jpeg, Wbmp, } impl WebFormat { pub fn from_id(s: &str) -> Option { Some(match s.to_ascii_lowercase().as_str() { "gif" => WebFormat::Gif, "png8" | "png-8" => WebFormat::Png8, "png24" | "png-24" | "png" | "png32" => WebFormat::Png24, "jpeg" | "jpg" => WebFormat::Jpeg, "wbmp" => WebFormat::Wbmp, _ => return None, }) } pub fn ext(self) -> &'static str { match self { WebFormat::Gif => "gif", WebFormat::Png8 | WebFormat::Png24 => "png", WebFormat::Jpeg => "jpg", WebFormat::Wbmp => "wbmp", } } pub fn indexed(self) -> bool { matches!(self, WebFormat::Gif | WebFormat::Png8) } } /// Which metadata Save for Web embeds (JPEG only, like Photoshop's legacy exporter). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WebMetadata { None, Copyright, CopyrightAndContact, /// All the XMP, none of the photo's EXIF (camera, date taken, GPS). AllExceptCameraInfo, /// All the XMP and the photo's EXIF. All, } /// One optimisation preset (the right-hand side of the Save for Web dialog). #[derive(Clone, Debug, PartialEq)] pub struct WebSettings { pub format: WebFormat, pub palette: PaletteKind, pub colors: usize, /// Fixed opaque source palette in index order. pub color_table: Option>, /// Replacements applied after quantization, preserving pixel indices. pub color_table_edits: std::collections::BTreeMap, pub dither: Dither, /// 0..=1. pub dither_amount: f32, pub transparency: bool, /// Edge / background colour (0..=1 RGB); None = no matte (hard alpha threshold). pub matte: Option<[f32; 3]>, pub interlaced: bool, /// Web Snap: palette entries within this distance (0..=1 of the channel range) snap to the /// web-safe cube. pub web_snap: f32, /// JPEG quality 0..=100. pub quality: u8, pub progressive: bool, pub optimized: bool, pub embed_icc: bool, pub metadata: WebMetadata, pub convert_to_srgb: bool, } impl Default for WebSettings { fn default() -> Self { WebSettings { format: WebFormat::Png24, palette: PaletteKind::Selective, colors: 256, color_table: None, color_table_edits: Default::default(), dither: Dither::Diffusion, dither_amount: 0.88, transparency: true, matte: Some([1.0, 1.0, 1.0]), interlaced: false, web_snap: 0.0, quality: 60, progressive: false, optimized: true, embed_icc: false, metadata: WebMetadata::Copyright, convert_to_srgb: true, } } } fn hex3(s: &str) -> Option<[f32; 3]> { let s = s.trim_start_matches('#'); let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| f32::from(v) / 255.0); (s.len() == 6).then_some(())?; Some([b(0)?, b(2)?, b(4)?]) } impl WebSettings { /// Settings from command parameters (missing keys keep the defaults; Photoshop's preset /// names such as "GIF 128 Dithered" or "JPEG High" are accepted as `"preset"`). /// /// Without a preset there is no matte, as in Photoshop's scripted Save for Web /// (`ExportOptionsSaveForWeb` left at its defaults): a GIF or PNG-8 keeps the colour of its /// partly transparent pixels instead of lightening them towards white (#4115). Formats /// without transparency still go over white. pub fn from_params(p: &Value, cmd: &str) -> Result { let mut st = WebSettings { matte: None, ..WebSettings::default() }; if let Some(preset) = p.get("preset").and_then(Value::as_str) { st = preset_settings(preset).ok_or_else(|| bad(cmd, format!("unknown preset `{preset}`")))?; } let s = |k: &str| p.get(k).and_then(Value::as_str); if let Some(f) = s("format") { st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp)")))?; } if let Some(k) = s("palette") { st.palette = match k { "restrictive" | "web" => PaletteKind::Web, k => PaletteKind::from_id(k).ok_or_else(|| { bad(cmd, format!("unknown palette `{k}` (perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform)")) })?, }; } if let Some(n) = crate::commands::int(p, "colors").filter(|v| *v > 0).map(|v| v as u64) { st.colors = (n as usize).clamp(2, 256); } if let Some(d) = s("dither") { st.dither = match d { "none" | "noDither" => Dither::None, "diffusion" => Dither::Diffusion, "pattern" => Dither::Pattern, "noise" => Dither::Noise, d => return Err(bad(cmd, format!("unknown dither `{d}` (none|diffusion|pattern|noise)"))), }; } if let Some(a) = p.get("ditherAmount").and_then(Value::as_f64) { st.dither_amount = (a as f32 / 100.0).clamp(0.0, 1.0); } if let Some(b) = p.get("transparency").and_then(Value::as_bool) { st.transparency = b; } match s("matte") { Some("none") => st.matte = None, Some(c) => st.matte = Some(hex3(c).ok_or_else(|| bad(cmd, "matte is \"none\" or \"#rrggbb\""))?), None => {} } for (k, slot) in [ ("interlaced", &mut st.interlaced), ("progressive", &mut st.progressive), ("optimized", &mut st.optimized), ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb), ] { if let Some(b) = p.get(k).and_then(Value::as_bool) { *slot = b; } } if let Some(w) = p.get("webSnap").and_then(Value::as_f64) { st.web_snap = (w as f32 / 100.0).clamp(0.0, 1.0); } if let Some(q) = p.get("quality").and_then(Value::as_f64) { st.quality = q.clamp(0.0, 100.0).round() as u8; } if let Some(m) = s("metadata") { st.metadata = match m { "none" => WebMetadata::None, "copyright" => WebMetadata::Copyright, "copyrightAndContact" | "contact" => WebMetadata::CopyrightAndContact, "allExceptCameraInfo" => WebMetadata::AllExceptCameraInfo, "all" => WebMetadata::All, m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)"))), }; } if let Some(table) = p.get("colorTable") { let table = table.as_array().filter(|a| (1..=256).contains(&a.len())).ok_or_else(|| bad(cmd, "colorTable must contain 1–256 #rrggbb colours"))?; st.color_table = Some( table .iter() .map(|v| v.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTable entries must be #rrggbb colours"))) .collect::>>()?, ); } if let Some(edits) = p.get("colorTableEdits") { let edits = edits .as_object() .filter(|m| m.len() <= 256) .ok_or_else(|| bad(cmd, "colorTableEdits must be an object of palette indices to #rrggbb colours"))?; let table = st.color_table.as_ref().ok_or_else(|| bad(cmd, "colorTableEdits requires colorTable"))?; for (index, value) in edits { let index = index.parse::().ok().filter(|i| *i < table.len()).ok_or_else(|| bad(cmd, "colorTableEdits index is outside colorTable"))?; let color = value.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTableEdits values must be #rrggbb colours"))?; st.color_table_edits.insert(index, color); } } Ok(st) } } impl WebSettings { /// The settings as command parameters (the inverse of [`WebSettings::from_params`]). pub fn to_params(&self) -> Value { let format = match self.format { WebFormat::Gif => "gif", WebFormat::Png8 => "png8", WebFormat::Png24 => "png24", WebFormat::Jpeg => "jpeg", WebFormat::Wbmp => "wbmp", }; let palette = match self.palette { PaletteKind::Exact => "exact", PaletteKind::SystemMac => "systemMac", PaletteKind::SystemWindows => "systemWindows", PaletteKind::Web => "restrictive", PaletteKind::Uniform => "uniform", PaletteKind::Perceptual => "perceptual", PaletteKind::Selective => "selective", PaletteKind::Adaptive => "adaptive", }; let dither = match self.dither { Dither::None => "none", Dither::Diffusion => "diffusion", Dither::Pattern => "pattern", Dither::Noise => "noise", }; let metadata = match self.metadata { WebMetadata::None => "none", WebMetadata::Copyright => "copyright", WebMetadata::CopyrightAndContact => "copyrightAndContact", WebMetadata::AllExceptCameraInfo => "allExceptCameraInfo", WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); if let Some(table) = &self.color_table { p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::>()); } if !self.color_table_edits.is_empty() { p["colorTableEdits"] = json!(self.color_table_edits.iter().map(|(i, c)| (i.to_string(), rgb_hex(*c))).collect::>()); } p } } /// A palette colour in command/control-channel notation. pub fn rgb_hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } /// Save for Web's named presets. pub const PRESETS: [&str; 12] = [ "GIF 128 Dithered", "GIF 128 No Dither", "GIF 32 Dithered", "GIF 32 No Dither", "GIF 64 Dithered", "GIF 64 No Dither", "GIF Restrictive", "JPEG High", "JPEG Low", "JPEG Medium", "PNG-24", "PNG-8 128 Dithered", ]; pub fn preset_settings(name: &str) -> Option { let d = WebSettings::default(); let gif = |colors: usize, dither: bool| WebSettings { format: WebFormat::Gif, colors, dither: if dither { Dither::Diffusion } else { Dither::None }, ..WebSettings::default() }; Some(match name { "GIF 128 Dithered" => gif(128, true), "GIF 128 No Dither" => gif(128, false), "GIF 32 Dithered" => gif(32, true), "GIF 32 No Dither" => gif(32, false), "GIF 64 Dithered" => gif(64, true), "GIF 64 No Dither" => gif(64, false), "GIF Restrictive" => WebSettings { palette: PaletteKind::Web, ..gif(256, true) }, "JPEG High" => WebSettings { format: WebFormat::Jpeg, quality: 60, ..d }, "JPEG Low" => WebSettings { format: WebFormat::Jpeg, quality: 10, ..d }, "JPEG Medium" => WebSettings { format: WebFormat::Jpeg, quality: 30, ..d }, "JPEG Maximum" => WebSettings { format: WebFormat::Jpeg, quality: 100, ..d }, "JPEG Very High" => WebSettings { format: WebFormat::Jpeg, quality: 80, ..d }, "PNG-24" => WebSettings { format: WebFormat::Png24, ..d }, "PNG-8 128 Dithered" => WebSettings { format: WebFormat::Png8, colors: 128, ..d }, _ => return None, }) } // ---------- the image ---------- /// The document as Save for Web sees it: RGB (converted to sRGB unless told otherwise), resized /// by `"width"`/`"height"`/`"percent"`, flattened. Returns the document and the scale factors. pub fn web_document(doc: &Document, p: &Value, st: &WebSettings) -> Result<(Document, f64, f64)> { let mut tmp = Session::new(); tmp.add_document(doc.clone(), None); let mode = doc.mode; if mode != photocraft_color::ColorMode::Rgb { tmp.execute("image.mode.rgb", json!({}))?; } if st.convert_to_srgb && doc.icc_profile.is_some() { // Documents without a profile are treated as sRGB already. tmp.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "perceptual"}))?; } let (w0, h0) = (f64::from(doc.size.width), f64::from(doc.size.height)); let pct = p.get("percent").and_then(Value::as_f64).filter(|v| *v > 0.0); let w = p.get("width").and_then(Value::as_f64).filter(|v| *v > 0.0); let h = p.get("height").and_then(Value::as_f64).filter(|v| *v > 0.0); let (nw, nh) = match (pct, w, h) { (Some(pc), _, _) => (w0 * pc / 100.0, h0 * pc / 100.0), (_, Some(w), Some(h)) => (w, h), (_, Some(w), None) => (w, h0 * w / w0), (_, None, Some(h)) => (w0 * h / h0, h), _ => (w0, h0), }; let (nw, nh) = (nw.round().max(1.0), nh.round().max(1.0)); if (nw - w0).abs() >= 1.0 || (nh - h0).abs() >= 1.0 { let resample = p.get("resample").and_then(Value::as_str).unwrap_or("bicubic"); tmp.execute("image.imageSize", json!({"width": nw, "height": nh, "resample": resample}))?; } let out = (*tmp.active().ok_or(EngineError::NoDocument)?.doc).clone(); Ok((out, nw / w0, nh / h0)) } /// An optimised image. #[derive(Clone, Debug)] pub struct Optimized { pub bytes: Vec, pub ext: &'static str, pub width: u32, pub height: u32, /// Colours in the palette (indexed formats). pub colors: Option, /// Opaque source palette before replacements (empty for non-indexed formats). pub color_table: Vec<[u8; 3]>, pub transparent_index: Option, /// Indexed pixels for previews; empty for non-indexed formats or when preview is disabled. pub indices: Vec, /// What the optimised file looks like, straight RGBA8 (for the dialog's previews). pub preview: Vec, } fn to8(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } /// XMP to embed for the metadata choice. pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { let xmp = doc.metadata.xmp.as_deref()?; match m { WebMetadata::None => None, WebMetadata::All | WebMetadata::AllExceptCameraInfo => Some(xmp.to_string()), WebMetadata::Copyright | WebMetadata::CopyrightAndContact => { let info = crate::file_cmds::read_file_info(Some(xmp)); let mut keep = serde_json::Map::new(); let mut keys = vec!["copyright", "copyrightStatus", "copyrightUrl"]; if m == WebMetadata::CopyrightAndContact { keys.extend(["author", "authorTitle"]); } for k in keys { if let Some(v) = info.get(k).filter(|v| v.as_str().is_some_and(|s| !s.is_empty()) || v.is_array()) { keep.insert(k.into(), v.clone()); } } (!keep.is_empty()).then(|| crate::file_cmds::write_file_info(None, &Value::Object(keep))) } } } /// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436). pub fn web_exif(doc: &Document, m: WebMetadata) -> Option<&[u8]> { doc.metadata.exif.as_deref().map(Vec::as_slice).filter(|_| m == WebMetadata::All) } /// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB). fn web_warnings(st: &WebSettings, exif: Option<&[u8]>, dpi: f32) -> Vec { exif.filter(|x| st.format == WebFormat::Jpeg && !photocraft_codecs::web::jpeg_exif_fits(x, Some(dpi))) .map(|x| photocraft_codecs::FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: x.len() }.to_string()) .into_iter() .collect() } /// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] pub fn optimize( px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, exif: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool, ) -> Result { if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) { return Err(other("slice is outside the pixel buffer")); } let (w, h) = (rect.width() as usize, rect.height() as usize); let row = |y: i32| { let o = y as usize * bw; px.get(o + rect.x0 as usize..o + rect.x1 as usize).unwrap_or(&[]) }; let region = || (rect.y0..rect.y1).flat_map(row); let matte = st.matte.unwrap_or([1.0, 1.0, 1.0]); let over = |p: &[f32; 4]| -> [f32; 4] { [p[0] * p[3] + matte[0] * (1.0 - p[3]), p[1] * p[3] + matte[1] * (1.0 - p[3]), p[2] * p[3] + matte[2] * (1.0 - p[3]), 1.0] }; let (ww, hh) = (w as u32, h as u32); let e = |e: photocraft_codecs::CodecError| other(e); let mut look: Vec = Vec::new(); let mut color_table = Vec::new(); let mut transparent_index = None; let mut indices = Vec::new(); let (bytes, colors) = match st.format { WebFormat::Png24 => { let alpha = st.transparency && region().any(|p| p[3] < 1.0); let k = if alpha { 4 } else { 3 }; let mut data = Vec::with_capacity(w * h * k); for p in region() { let q = if alpha { *p } else { over(p) }; data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); } if preview { look = if alpha { data.clone() } else { data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() }; } let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; let meta = photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta); let opts = photocraft_codecs::EncodeOptions { png_interlaced: st.interlaced, embed_icc: icc.is_some(), embed_metadata: xmp.is_some(), ..Default::default() }; (photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &opts).map_err(e)?, None) } WebFormat::Jpeg => { let mut rgb = Vec::with_capacity(w * h * 3); for p in region() { let q = over(p); rgb.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2])]); } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), exif, xmp) .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { Ok(img) => img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(), Err(_) => rgb.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect(), }; } (bytes, None) } WebFormat::Wbmp => { let mut gray: Vec = region() .map(|p| { let q = over(p); 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2] }) .collect(); let method = match st.dither { Dither::None => quantize::BitmapMethod::Threshold, Dither::Pattern => quantize::BitmapMethod::Pattern, _ => quantize::BitmapMethod::Diffusion, }; quantize::to_bitmap(&mut gray, w, method); let white: Vec = gray.iter().map(|v| *v > 0.5).collect(); if preview { look = white.iter().flat_map(|b| if *b { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect(); } (photocraft_codecs::web::encode_wbmp(ww, hh, &white).map_err(e)?, Some(2)) } WebFormat::Gif | WebFormat::Png8 => { let transparent = st.transparency && region().any(|p| p[3] < 0.5); // Partially transparent edges blend with the matte; with transparency off everything does. let mut work: Vec<[f32; 4]> = region() .map(|p| { if st.transparency && p[3] < 0.5 { [0.0, 0.0, 0.0, 0.0] } else if st.transparency && st.matte.is_none() { [p[0], p[1], p[2], 1.0] } else { over(p) } }) .collect(); let room = if transparent { st.colors.saturating_sub(1).max(2) } else { st.colors }; let mut pal = if let Some(table) = &st.color_table { if table.is_empty() || table.len() > if transparent { 255 } else { 256 } { return Err(other("colorTable has too many entries for this image's transparency")); } table.clone() } else { // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); if src.is_empty() { src = work.clone(); } quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)? }; if st.web_snap > 0.0 && st.color_table.is_none() { let tol = (st.web_snap * 255.0) as u8; for c in &mut pal { let snap = c.map(|x| ((f32::from(x) / 51.0).round() * 51.0) as u8); if c.iter().zip(snap).all(|(a, b)| a.abs_diff(b) <= tol) { *c = snap; } } } if transparent { pal.truncate(255); } let first = pal.first().copied().ok_or_else(|| other("empty colour table"))?; color_table = pal.clone(); let t = transparent.then(|| { pal.push(first); pal.len() - 1 }); transparent_index = t; let idx = quantize_rows(&mut work, w, &pal, st.dither, st.dither_amount, t); for (index, color) in &st.color_table_edits { let slot = pal.get_mut(*index).filter(|_| Some(*index) != t).ok_or_else(|| other("colour edit is outside the opaque palette"))?; *slot = *color; } if preview { look = idx .iter() .flat_map(|k| { let c = pal.get(*k as usize).copied().unwrap_or(first); [c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }] }) .collect(); } let tt = t.map(|v| v as u8); let bytes = if st.format == WebFormat::Gif { photocraft_codecs::web::encode_gif_indexed(ww, hh, &idx, &pal, tt, st.interlaced).map_err(e)? } else { photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None, None).map_err(e)? }; if preview { indices = idx; } (bytes, Some(pal.len())) } }; Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, color_table, transparent_index, indices, preview: look }) } /// Nearest palette entry through a lazily filled 6-bit-per-channel inverse colour map: each /// 4×4×4 cell is resolved once (by its centre), so large images cost a table lookup per pixel /// instead of a palette scan. `t` (the transparent index) is never returned. struct InverseMap<'a> { pal: &'a [[u8; 3]], t: Option, lut: Vec, } impl<'a> InverseMap<'a> { fn new(pal: &'a [[u8; 3]], t: Option) -> Self { InverseMap { pal, t, lut: vec![u16::MAX; 1 << 18] } } #[inline] fn get(&mut self, c: [f32; 3]) -> usize { let q = c.map(|v| ((v.clamp(0.0, 1.0) * 255.0).round() as usize) >> 2); let key = q[0] << 12 | q[1] << 6 | q[2]; let v = self.lut[key]; if v != u16::MAX { return usize::from(v); } let center = q.map(|v| (v * 4 + 2) as f32); let mut best = (f32::MAX, 0usize); for (i, e) in self.pal.iter().enumerate() { if Some(i) == self.t { continue; } let d: f32 = (0..3).map(|k| (center[k] - f32::from(e[k])).powi(2)).sum(); if d < best.0 { best = (d, i); } } self.lut[key] = best.1 as u16; best.1 } } fn hash01(x: usize, y: usize) -> f32 { let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ 0x2545_f491; h ^= h >> 13; h = h.wrapping_mul(0x5bd1_e995); h ^= h >> 15; (h & 0xffff) as f32 / 65535.0 } /// Palette mapping with dithering (Floyd–Steinberg diffusion, 8×8 Bayer pattern, noise), like /// `algo::quantize::quantize` but through [`InverseMap`]. `y0` keeps patterns continuous across /// bands. Pixels with alpha < ½ take index `t`. fn quantize_band(px: &mut [[f32; 4]], w: usize, y0: usize, pal: &[[u8; 3]], dither: Dither, amount: f32, t: Option) -> Vec { let h = px.len() / w.max(1); let mut map = InverseMap::new(pal, t); let mut idx = vec![0u8; px.len()]; let opaque_n = pal.len() - usize::from(t.is_some()); let spread = (1.0 / (opaque_n as f32).cbrt().max(1.0)).min(0.5) * amount.clamp(0.0, 1.0); let a = amount.clamp(0.0, 1.0); let mut cur = vec![[0.0f32; 3]; if dither == Dither::Diffusion { w + 2 } else { 0 }]; let mut next = cur.clone(); for y in 0..h { if dither == Dither::Diffusion { std::mem::swap(&mut cur, &mut next); next.iter_mut().for_each(|e| *e = [0.0; 3]); } for x in 0..w { let i = y * w + x; let p = px[i]; if p[3] < 0.5 && let Some(t) = t { idx[i] = t as u8; continue; } let mut c = [p[0], p[1], p[2]]; match dither { Dither::None => {} Dither::Diffusion => (0..3).for_each(|k| c[k] += cur[x + 1][k]), Dither::Pattern => { let d = quantize::bayer8(x, y0 + y) * spread; c = c.map(|v| v + d); } Dither::Noise => { let d = (hash01(x, y0 + y) - 0.5) * spread; c = c.map(|v| v + d); } } let c = c.map(|v| v.clamp(0.0, 1.0)); let k = map.get(c); idx[i] = k as u8; if dither == Dither::Diffusion { let e = pal[k].map(|v| f32::from(v) / 255.0); let d: [f32; 3] = std::array::from_fn(|q| (c[q] - e[q]) * a); for q in 0..3 { cur[x + 2][q] += d[q] * 7.0 / 16.0; next[x][q] += d[q] * 3.0 / 16.0; next[x + 1][q] += d[q] * 5.0 / 16.0; next[x + 2][q] += d[q] / 16.0; } } } } idx } /// [`quantize_band`] over the whole image, in parallel bands of 64 rows when the dither is /// position-independent. Error diffusion carries error down the image, so it stays sequential. fn quantize_rows(px: &mut [[f32; 4]], w: usize, pal: &[[u8; 3]], dither: Dither, amount: f32, t: Option) -> Vec { let h = px.len() / w.max(1); if dither == Dither::Diffusion || h < 128 || cfg!(target_arch = "wasm32") { return quantize_band(px, w, 0, pal, dither, amount, t); } #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; let band = w * 64; let mut idx = vec![0u8; px.len()]; px.par_chunks_mut(band) .zip(idx.par_chunks_mut(band)) .enumerate() .for_each(|(i, (p, out))| out.copy_from_slice(&quantize_band(p, w, i * 64, pal, dither, amount, t))); idx } // Single-threaded on wasm (the early return above always takes this path there). #[cfg(target_arch = "wasm32")] quantize_band(px, w, 0, pal, dither, amount, t) } /// A file-name-safe slice name. fn slice_file(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_') { c } else { '_' }).collect(); let s = if s.is_empty() { "slice".into() } else { s }; portable_generated_stem(s) } /// Generated filenames should remain usable on Windows even when a document or slice is named /// after a reserved device. Caller-supplied path components are still rejected below. fn portable_generated_stem(stem: String) -> String { portable_device_name(stem) } fn html_escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } /// Imported slice URLs are document data. Only ordinary web/contact links and relative links /// become anchors; an unsupported scheme leaves the image unlinked. fn safe_slice_url(url: &str) -> bool { if url.is_empty() || url.trim() != url || url.chars().any(|c| c.is_control() || c == '\\') { return false; } let delimiter = url.find(['/', '?', '#']).unwrap_or(url.len()); let Some(colon) = url.find(':').filter(|pos| *pos < delimiter) else { return true; }; let scheme = url.get(..colon).unwrap_or_default().to_ascii_lowercase(); let rest = url.get(colon + 1..).unwrap_or_default(); match scheme.as_str() { "http" | "https" | "ftp" | "ftps" => rest.strip_prefix("//").is_some_and(|host| !host.is_empty() && !host.starts_with('/')), "mailto" | "tel" => !rest.is_empty(), _ => false, } } /// Keep the image folder relative to the selected export directory on every platform. fn web_images_folder(images: &str, cmd: &str) -> Result { if images.is_empty() { return Ok(String::new()); } let trimmed = images.trim_end_matches(['/', '\\']); if trimmed.is_empty() { return Err(bad(cmd, "imagesFolder must be a relative folder name")); } let parts = asset_path_components(trimmed).map_err(|e| bad(cmd, format!("imagesFolder: {e}")))?; Ok(parts.join("/")) } fn web_output_path(dir: &str, relative: &str) -> Result { let root = if dir.is_empty() { "." } else { dir }; asset_output_path(root, relative).map(|(actual, _)| actual) } fn web_derived_path(dir: &str, relative: &str, native_paths: bool) -> Result { if native_paths { web_output_path(dir, relative) } else { // Browser downloads use logical names, without a host filesystem to inspect. asset_path_components(relative)?; Ok(join(dir, relative)) } } fn scale_rect(r: Rect, sx: f64, sy: f64) -> Rect { Rect::new( (f64::from(r.x0) * sx).round() as i32, (f64::from(r.y0) * sy).round() as i32, (f64::from(r.x1) * sx).round() as i32, (f64::from(r.y1) * sy).round() as i32, ) } /// The HTML table for `cells` (rect, td body) over a `w`×`h` image: one column per distinct x /// edge, one row per y edge, colspan/rowspan per slice, and a spacer row fixing column widths. fn html_table(title: &str, w: u32, h: u32, cells: &[(Rect, String, String)], spacer: Option<&str>) -> String { let mut xs: Vec = cells.iter().flat_map(|(r, _, _)| [r.x0, r.x1]).collect(); let mut ys: Vec = cells.iter().flat_map(|(r, _, _)| [r.y0, r.y1]).collect(); xs.sort_unstable(); xs.dedup(); ys.sort_unstable(); ys.dedup(); let col = |x: i32| xs.iter().position(|v| *v == x).unwrap_or(0); let row = |y: i32| ys.iter().position(|v| *v == y).unwrap_or(0); let (nc, nr) = (xs.len().saturating_sub(1), ys.len().saturating_sub(1)); let mut taken = vec![false; nc * nr]; let mut by_row: Vec> = vec![Vec::new(); nr]; for (r, attrs, body) in cells { let (c0, c1, r0, r1) = (col(r.x0), col(r.x1), row(r.y0), row(r.y1)); if (r0..r1).any(|j| (c0..c1).any(|i| taken[j * nc + i])) { continue; // overlapped (the caller cuts slices into visible pieces, so this can't happen) } for j in r0..r1 { for i in c0..c1 { taken[j * nc + i] = true; } } let mut td = String::from("\t\t 1 { td.push_str(&format!(" colspan=\"{}\"", c1 - c0)); } if r1 - r0 > 1 { td.push_str(&format!(" rowspan=\"{}\"", r1 - r0)); } td.push_str(attrs); td.push('>'); td.push_str(body); td.push_str("\n"); by_row[r0].push((c0, td)); } let mut s = format!( "\n\n{t}\n\n\n\n\n\n", t = html_escape(title) ); for (j, mut tds) in by_row.into_iter().enumerate() { tds.sort_by_key(|(c, _)| *c); s.push_str("\t\n"); for (_, td) in tds { s.push_str(&td); } if let Some(sp) = spacer { s.push_str(&format!("\t\t\n", html_escape(sp), ys[j + 1] - ys[j])); } s.push_str("\t\n"); } if let Some(sp) = spacer { s.push_str("\t\n"); for i in 0..nc { s.push_str(&format!("\t\t\n", html_escape(sp), xs[i + 1] - xs[i])); } s.push_str("\t\t\n\t\n"); } s.push_str("
\n\t\t\t\"\"
\n\t\t\t\"\"
\n\n\n\n"); s } /// File › Export › Save for Web (Legacy). fn save_for_web(s: &mut Session, p: &Value) -> Result { save_for_web_impl(s, p, &mut write_file, true) } /// Save for Web through a platform writer (native files or browser downloads). /// The writer receives every image, spacer and HTML file. A write error stops the export; /// only a complete export remembers its settings and fires the export event. A custom writer /// that targets the local filesystem must enforce its own path confinement. pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { save_for_web_impl(s, p, write, false) } /// Settings to seed the Save for Web dialog from on the next launch: `file_menu` is session /// state, so a complete export also writes the preferences' remembered dialog settings (like /// Fill, Liquify and the filter dialogs). The destination is per export, never remembered. fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) { let mut m = settings.clone(); if let Some(o) = m.as_object_mut() { for k in ["path", "dir", "trustedSliceHtml"] { o.remove(k); } } s.prefs.edit(|p| { p.dialogs.insert(cmd.to_string(), m); }); } fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>, native_paths: bool) -> Result { let cmd = "file.export.saveForWebLegacy"; if let Some(authorize) = s.authorize { authorize(cmd, p)?; } // Direct platform calls must export the moved selection as it appears on the canvas, // just as Session::execute does before dispatching the native command. crate::float_cmds::before_command(s, cmd)?; let st = WebSettings::from_params(p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); let (wdoc, sx, sy) = web_document(&doc, p, &st)?; let buf = photocraft_compose::flatten(&wdoc); let bw = wdoc.size.width as usize; let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); let xmp = web_xmp(&doc, st.metadata); let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); let chosen: Vec<&ResolvedSlice> = all .iter() .filter(|r| match (&numbers, pick) { (Some(n), _) => n.contains(&(r.number as u64)), (None, "user") => r.origin != photocraft_doc::SliceOrigin::Auto, _ => true, }) .collect(); let single = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()); let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()); let mut last_web = p.clone(); // A trusted-markup decision applies to this export only, never to another document opened // with the dialog's remembered settings. if let Some(last) = last_web.as_object_mut() { last.remove("trustedSliceHtml"); } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; return Ok( json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); let html = p.get("html").and_then(Value::as_bool).unwrap_or(false); let images = web_images_folder(p.get("imagesFolder").and_then(Value::as_str).unwrap_or("images"), cmd)?; let trusted_slice_html = p.get("trustedSliceHtml").and_then(Value::as_bool).unwrap_or(false); let base = portable_generated_stem(single.map(stem).unwrap_or_else(|| slices::base_name(&doc))); let mut files = Vec::new(); let mut cells: Vec<(Rect, String, String)> = Vec::new(); let mut total = 0usize; // Output names are unique (case-insensitively, for macOS and Windows file systems): two // slices named alike, or names that sanitize alike, must not overwrite each other. The HTML // page's spacer image is reserved. let mut used: std::collections::HashSet = std::collections::HashSet::new(); if html { used.insert("spacer.gif".into()); } let mut unique = |stem: &str, ext: &str| { let mut name = format!("{stem}.{ext}"); let mut k = 2u32; while !used.insert(name.to_lowercase()) { name = format!("{stem}_{k}.{ext}"); k = k.saturating_add(1); } name }; // Slices never overlap here: one covered by later slices comes as its visible pieces (see // `slices::resolve`), each its own image and cell, as Photoshop's subslices. A no-image cell's // text goes into its first piece only. let mut texted = std::collections::HashSet::new(); for r in &chosen { let piece = scale_rect(r.rect, sx, sy).intersect(&wdoc.bounds()); if piece.is_empty() { continue; } let stored = r.id.and_then(|id| doc.slices.get(id)); if r.kind == SliceKind::NoImage { let text = stored .filter(|s| texted.insert(s.id)) .map(|s| if trusted_slice_html && s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }) .unwrap_or_default(); let bg = stored.and_then(|s| s.background).map(|c| format!(" bgcolor=\"#{:02X}{:02X}{:02X}\"", c[1], c[2], c[3])).unwrap_or_default(); cells.push((piece, bg, text)); continue; } let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; let name = unique(&slice_file(&r.name), o.ext); let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; let out = join(&dir, &rel); write(&web_derived_path(&dir, &rel, native_paths)?, &o.bytes)?; total += o.bytes.len(); files.push(out); let alt = stored.map_or(String::new(), |s| html_escape(&s.alt)); let img = format!("\n\t\t\t\"{alt}\"", html_escape(&rel), piece.width(), piece.height()); let body = match stored.filter(|s| safe_slice_url(&s.url)) { Some(s) => { let target = if s.target.is_empty() { String::new() } else { format!(" target=\"{}\"", html_escape(&s.target)) }; format!("\n\t\t\t{img}", html_escape(&s.url)) } None => img, }; cells.push((piece, String::new(), body)); } let mut html_path = None; if html { let grid = cells.len() > 1; let spacer = grid.then(|| if images.is_empty() { "spacer.gif".to_string() } else { format!("{images}/spacer.gif") }); if let Some(sp) = &spacer { let gif = photocraft_codecs::web::encode_gif_indexed(1, 1, &[0], &[[0, 0, 0]], Some(0), false).map_err(other)?; write(&web_derived_path(&dir, sp, native_paths)?, &gif)?; } let page = html_table(&base, wdoc.size.width, wdoc.size.height, &cells, spacer.as_deref()); let hp = join(&dir, &format!("{base}.html")); write(&web_derived_path(&dir, &format!("{base}.html"), native_paths)?, page.as_bytes())?; html_path = Some(hp); } remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); Ok( json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height, "warnings": warnings}), ) } // ---------- Export Preferences / Quick Export ---------- fn export_preferences(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.exportPreferences"; let mut values = serde_json::Map::new(); for (k, key) in [ ("quickExportFormat", "export.quickExportFormat"), ("quickExportLocation", "export.quickExportLocation"), ("jpegQuality", "export.jpegQuality"), ("webpLossless", "export.webpLossless"), ("webpQuality", "export.webpQuality"), ("metadata", "export.metadata"), ("convertToSrgb", "export.convertToSrgb"), ] { if let Some(v) = p.get(k) { values.insert(key.into(), v.clone()); } } if !values.is_empty() { s.execute("prefs.set", json!({"values": values})).map_err(|e| bad(cmd, e.to_string()))?; } Ok(json!({"section": "export", "values": s.prefs().get("export").unwrap_or(Value::Null)})) } /// The Quick Export format's extension (Export Preferences › Format). fn quick_export_ext(s: &Session) -> String { serde_json::to_value(s.prefs().export.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()) } /// Where Quick Export writes the active document with no `path` given: `.` beside /// its file, when Export Preferences › Location is Same Folder and the document has a file. pub fn quick_export_target(s: &Session) -> Option { let same = s.prefs().export.quick_export_location == crate::prefs::ExportLocation::SameFolder; let dp = s.active()?.path.as_deref().filter(|_| same)?; let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); Some(join(&dir, &format!("{}.{}", stem(dp), quick_export_ext(s)))) } /// Whether [`quick_export_target`] is the active document's own file (a document opened from /// `photo.png` and exported as PNG), which Quick Export must not replace without asking (#6599). pub fn quick_export_target_is_own_file(s: &Session) -> bool { let own = s.active().and_then(|d| d.path.as_deref()); quick_export_target(s).zip(own).is_some_and(|(target, own)| crate::file_cmds::same_file(&target, own)) } /// File › Export › Quick Export as : the format, quality, metadata, colour space and /// location from Export Preferences. `path` overrides the location ("ask" needs one). fn quick_export(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.quickExport"; let prefs = s.prefs().export.clone(); let fmt = quick_export_ext(s); let path = match p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()) { Some(v) => v.to_string(), None => { let Some(target) = quick_export_target(s) else { return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")); }; // The document's own file is never replaced by an export the user didn't name (#6599). if quick_export_target_is_own_file(s) { return Err(bad(cmd, format!("the export would replace the document's own file {target}; give a `path`"))); } target } }; // The name typed in the save dialog wins over Export Preferences' format: Quick Export as PNG // renamed to `photo.jpeg` must write a JPEG, not PNG bytes upload checks refuse (#8519). let fmt = match crate::file_cmds::extension(&path).as_deref() { None => fmt, Some("jpg" | "jpeg") => "jpg".into(), Some(e @ ("png" | "gif" | "webp" | "exr")) => e.into(), Some(e) => return Err(bad(cmd, format!("Quick Export writes PNG, JPEG, GIF, WebP or EXR, not .{e}; use Export As"))), }; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); if fmt == "exr" { // EXR keeps floating-point scene values. The ordinary exporter converts to linear // light; the web/sRGB pipeline would instead clamp HDR and negative values. let opts = photocraft_io::ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; let out = photocraft_io::export(&doc, "exr", &opts).map_err(other)?; write_file(&path, &out.bytes)?; crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"path": path, "format": fmt, "bytes": out.bytes.len(), "warnings": out.warnings})); } let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let mut o = serde_json::Map::new(); o.insert( "metadata".into(), json!(if metadata == "all" { "all" } else if metadata == "none" { "none" } else { "copyright" }), ); o.insert("convertToSrgb".into(), json!(prefs.convert_to_srgb)); o.insert("transparency".into(), json!(true)); o.insert("matte".into(), json!("#ffffff")); match fmt.as_str() { "jpg" => { o.insert("format".into(), json!("jpeg")); o.insert("quality".into(), json!(prefs.jpeg_quality.min(100))); } "gif" => { o.insert("format".into(), json!("gif")); } "webp" => { // WebP uses the regular exporter. Its optional quality is in the Photoshop-style // 0–12 save scale: invert that mapping so the codec receives the chosen 1–100. // Keeping quality absent preserves the historical lossless Quick Export behavior. let (wdoc, _, _) = web_document(&doc, &json!({}), &WebSettings { convert_to_srgb: prefs.convert_to_srgb, ..Default::default() })?; let quality = if prefs.webp_lossless { None } else { Some((f64::from(prefs.webp_quality.clamp(1, 100)) - 1.0) * 12.0 / 99.0) }; let warnings = crate::file_cmds::save_doc(&wdoc, &path, quality)?; crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"path": path, "format": fmt, "warnings": warnings})); } _ => { o.insert("format".into(), json!("png24")); } } let q = Value::Object(o); let st = WebSettings::from_params(&q, cmd)?; let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); let exif = web_exif(&doc, st.metadata); let opt = optimize( &buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), exif, xmp.as_deref(), wdoc.resolution_dpi, false, )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len(), "warnings": web_warnings(&st, exif, wdoc.resolution_dpi)})) } // ---------- Generate › Image Assets ---------- /// One asset requested by a layer name. #[derive(Clone, Debug, PartialEq)] pub struct AssetSpec { /// Relative output path (folders allowed), extension included. pub file: String, /// png8 | png24 | png32 | jpg | gif | webp pub format: String, /// JPEG quality 1..=100. pub quality: Option, /// Uniform scale (1.0 = 100 %). pub scale: Option, /// Target width / height in px (`?` = keep aspect). pub width: Option, pub height: Option, } /// A `default` layer variant: size plus a folder (`low/`) or name suffix (`@2x`). #[derive(Clone, Debug, Default, PartialEq)] pub struct AssetDefault { pub scale: Option, pub width: Option, pub height: Option, pub folder: String, pub suffix: String, } fn unit_px(tok: &str, dpi: f64) -> Option> { if tok == "?" { return Some(None); } let (num, k) = if let Some(n) = tok.strip_suffix("px") { (n, 1.0) } else if let Some(n) = tok.strip_suffix("in") { (n, dpi) } else if let Some(n) = tok.strip_suffix("cm") { (n, dpi / 2.54) } else if let Some(n) = tok.strip_suffix("mm") { (n, dpi / 25.4) } else { (tok, 1.0) }; num.trim().parse::().ok().filter(|v| *v > 0.0).map(|v| Some(v * k)) } /// `200%` or `WxH` (units px/in/cm/mm, `?` wildcard) at the start of `item`; returns the size and /// the rest. fn parse_size(item: &str, dpi: f64) -> (Option, Option, Option, &str) { let item = item.trim(); let (first, rest) = item.split_once(char::is_whitespace).map_or((item, ""), |(a, b)| (a, b.trim())); if let Some(pc) = first.strip_suffix('%').and_then(|n| n.parse::().ok()).filter(|v| *v > 0.0) { return (Some(pc / 100.0), None, None, rest); } // "100x50", "2in x 1in", "?x100" if let Some((w, h)) = first.split_once(['x', 'X']).filter(|(w, h)| !w.is_empty() && !h.is_empty()) && let (Some(w), Some(h)) = (unit_px(w, dpi), unit_px(h, dpi)) { return (None, w, h, rest); } // Spaced form "2in x 1in name.png": three tokens. let toks: Vec<&str> = item.split_whitespace().collect(); if toks.len() >= 4 && toks[1].eq_ignore_ascii_case("x") && let (Some(w), Some(h)) = (unit_px(toks[0], dpi), unit_px(toks[2], dpi)) { // Skip exactly the three size tokens; searching for the name's first word could match inside the size. let mut rest = item; for _ in 0..3 { rest = rest.trim_start().trim_start_matches(|c: char| !c.is_whitespace()); } return (None, w, h, rest.trim_start()); } (None, None, None, item) } /// Parses a layer name into its asset specs (empty when it names no asset). pub fn parse_asset_name(name: &str, dpi: f64) -> Vec { let mut out = Vec::new(); for item in name.split([',', '+']) { let (scale, width, height, rest) = parse_size(item, dpi); let file = rest.trim(); let Some((stem_part, ext)) = file.rsplit_once('.') else { continue }; if stem_part.is_empty() { continue; } let ext_l = ext.to_ascii_lowercase(); let (format, quality, out_ext) = if let Some(q) = ext_l.strip_prefix("jpeg").or_else(|| ext_l.strip_prefix("jpg")) { let q = q.trim(); let quality = if q.is_empty() { None } else if let Some(pc) = q.strip_suffix('%') { pc.parse::().ok().map(|v| v.clamp(1, 100) as u8) } else { // 1–10 scale. q.parse::().ok().map(|v| (v.clamp(1, 10) * 10) as u8) }; if !q.is_empty() && quality.is_none() { continue; } ("jpg".to_string(), quality, if ext_l.starts_with("jpeg") { "jpeg" } else { "jpg" }) } else { match ext_l.as_str() { "png" | "png32" => ("png32".to_string(), None, "png"), "png24" => ("png24".to_string(), None, "png"), "png8" => ("png8".to_string(), None, "png"), "gif" => ("gif".to_string(), None, "gif"), "webp" => ("webp".to_string(), None, "webp"), _ => continue, } }; out.push(AssetSpec { file: format!("{stem_part}.{out_ext}"), format, quality, scale, width, height }); } out } /// Parses a `default …` layer name into its variants. pub fn parse_defaults(name: &str, dpi: f64) -> Option> { let rest = name.trim().strip_prefix("default")?; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { return None; } let mut v = Vec::new(); for item in rest.split([',', '+']) { let (scale, width, height, tail) = parse_size(item, dpi); let tail = tail.trim(); let (folder, suffix) = match tail.rfind('/') { Some(i) => (tail[..=i].to_string(), tail[i + 1..].to_string()), None => (String::new(), tail.to_string()), }; if scale.is_none() && width.is_none() && height.is_none() && folder.is_empty() && suffix.is_empty() { continue; } v.push(AssetDefault { scale, width, height, folder, suffix }); } Some(v) } fn asset_settings(spec: &AssetSpec) -> WebSettings { let base = WebSettings { transparency: true, matte: None, metadata: WebMetadata::None, ..WebSettings::default() }; match spec.format.as_str() { "jpg" => WebSettings { format: WebFormat::Jpeg, quality: spec.quality.unwrap_or(90), matte: Some([1.0, 1.0, 1.0]), ..base }, "png8" => WebSettings { format: WebFormat::Png8, dither: Dither::None, ..base }, "png24" => WebSettings { format: WebFormat::Png24, transparency: false, matte: Some([1.0, 1.0, 1.0]), ..base }, "gif" => WebSettings { format: WebFormat::Gif, dither: Dither::None, ..base }, _ => WebSettings { format: WebFormat::Png24, ..base }, } } /// Layer names and `default` folders are document data, so reject unsafe output components. fn asset_path_components(relative: &str) -> Result> { // Split both separator styles even on Unix, where a backslash would otherwise be a legal // filename. This also catches Windows drive, rooted, and UNC paths on every platform. let mut parts = Vec::new(); for part in relative.split(['/', '\\']) { if part.is_empty() || part == "." || part == ".." || part.ends_with('.') || part.ends_with(' ') || part.contains(':') || part.contains('\0') || is_windows_device_name(part) { return Err(other(format!("asset path {relative:?} has an unsafe component {part:?}"))); } parts.push(part); } Ok(parts) } /// Resolve a generated asset below the selected output directory. The atomic writer follows an /// existing file symlink, so reject it and directory symlinks before writing. fn asset_output_path(dir: &str, relative: &str) -> Result<(String, String)> { if dir.is_empty() { return Err(other("image assets need an output directory")); } let mut parts = asset_path_components(relative)?; // Keep the caller's spelling in the result (for example /var on macOS), while writes use // the canonical directory so a symlink in the selected root cannot move the destination. let mut reported = std::path::PathBuf::from(dir); for part in &parts { reported.push(part); } let file = parts.pop().ok_or_else(|| other(format!("asset path {relative:?} is empty")))?; let chosen_root: std::path::PathBuf = std::path::Path::new(dir).components().collect(); std::fs::create_dir_all(&chosen_root).map_err(|e| other(format!("asset output directory {dir:?}: {e}")))?; // The caller chose this root, so a symlink to an existing directory is intentional. All // document-derived components below it are checked against its canonical target. let root = std::fs::canonicalize(&chosen_root).map_err(|e| other(format!("asset output directory {dir:?}: {e}")))?; let mut parent = root.clone(); for part in parts { parent.push(part); match std::fs::symlink_metadata(&parent) { Ok(meta) if meta.file_type().is_symlink() => { return Err(other(format!("asset path {relative:?} crosses a symlink at {}", parent.display()))); } Ok(meta) if !meta.is_dir() => { return Err(other(format!("asset path {relative:?} has a non-directory at {}", parent.display()))); } Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => { std::fs::create_dir(&parent).map_err(|e| other(format!("asset folder {}: {e}", parent.display())))?; } Err(e) => return Err(other(format!("asset folder {}: {e}", parent.display()))), } let actual = std::fs::canonicalize(&parent).map_err(|e| other(format!("asset folder {}: {e}", parent.display())))?; if !actual.starts_with(&root) { return Err(other(format!("asset path {relative:?} escapes output directory {dir:?}"))); } parent = actual; } let parent = std::fs::canonicalize(&parent).map_err(|e| other(format!("asset folder {}: {e}", parent.display())))?; if !parent.starts_with(&root) { return Err(other(format!("asset path {relative:?} escapes output directory {dir:?}"))); } let output = parent.join(file); match std::fs::symlink_metadata(&output) { Ok(meta) if meta.file_type().is_symlink() => { return Err(other(format!("asset path {relative:?} points to a symlink"))); } Ok(meta) if !meta.is_file() => { return Err(other(format!("asset path {relative:?} is not a file"))); } Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(other(format!("asset path {relative:?}: {e}"))), } Ok((output.to_string_lossy().into_owned(), reported.to_string_lossy().into_owned())) } /// Exports every asset named by `doc`'s layers into `dir`. Returns (files, errors). /// /// Each file is written once per run (#6614): when two layers (or two default variants) name the /// same file, the one higher in the Layers panel keeps it and the other is reported in `errors`. /// Names are compared ignoring case, as `Logo.png` and `logo.png` are one file on Windows and /// macOS. pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let dpi = f64::from(doc.resolution_dpi.max(1.0)); let mut defaults: Option> = None; let mut by_layer: Vec> = Vec::new(); for (_, _, l) in doc.walk() { if let Some(d) = parse_defaults(&l.name, dpi) { defaults = Some(d); continue; } if matches!(l.content, LayerContent::Adjustment(_)) { continue; } by_layer.push(parse_asset_name(&l.name, dpi).into_iter().map(|spec| (l.id, spec)).collect()); } // Top layer first, as the Layers panel lists them (`walk` goes bottom to top). let jobs: Vec<(LayerId, AssetSpec)> = by_layer.into_iter().rev().flatten().collect(); let variants: Vec = match defaults { Some(d) if !d.is_empty() => d, _ => vec![AssetDefault::default()], }; let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = std::collections::HashSet::new(); for (id, spec) in &jobs { for v in &variants { let r = (|| -> Result { let (name_stem, ext) = spec.file.rsplit_once('.').unwrap_or((spec.file.as_str(), "png")); let rel = format!("{}{}{}.{ext}", v.folder, name_stem, v.suffix); let key = asset_path_components(&rel)?.join("/").to_lowercase(); if written.contains(&key) { return Err(other(format!("{rel}: another layer already writes this file; rename one of the layers"))); } let mut one = crate::layer_menu_cmds::layer_document(doc, *id)?; one.metadata = Default::default(); let (w0, h0) = (f64::from(one.size.width), f64::from(one.size.height)); // The layer's own size spec wins; default variants scale on top of it. let mut k = spec.scale.unwrap_or(1.0); let (mut tw, mut th) = (w0 * k, h0 * k); match (spec.width, spec.height) { (Some(w), Some(h)) => (tw, th) = (w, h), (Some(w), None) => (tw, th) = (w, h0 * w / w0), (None, Some(h)) => (tw, th) = (w0 * h / h0, h), _ => {} } k = v.scale.unwrap_or(1.0); tw *= k; th *= k; match (v.width, v.height) { (Some(w), Some(h)) => (tw, th) = (w, h), (Some(w), None) => (tw, th) = (w, th * w / tw), (None, Some(h)) => (tw, th) = (tw * h / th, h), _ => {} } let st = asset_settings(spec); let p = json!({"width": tw.round().max(1.0), "height": th.round().max(1.0)}); let (wdoc, _, _) = web_document(&one, &p, &WebSettings { convert_to_srgb: true, ..st.clone() })?; if spec.format == "webp" { let (out, reported) = asset_output_path(dir, &rel)?; crate::file_cmds::save_doc(&wdoc, &out, None)?; written.insert(key); return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; written.insert(key); Ok(reported) })(); match r { Ok(f) => files.push(f), Err(e) => errors.push(json!({"asset": spec.file, "error": e.to_string()})), } } } (files, errors) } /// `/-assets`. pub fn assets_dir(doc_path: &str) -> String { let p = std::path::Path::new(doc_path); let dir = p.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); join(&dir, &format!("{}-assets", stem(doc_path))) } fn image_assets(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let id: DocId = d.doc.id; let was = s.file_menu.image_assets.contains(&id); let on = p.get("on").and_then(Value::as_bool).unwrap_or(!was); if on { if !was { s.file_menu.image_assets.push(id); } } else { s.file_menu.image_assets.retain(|x| *x != id); } let d = s.active().ok_or(EngineError::NoDocument)?; let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).or_else(|| d.path.as_deref().map(assets_dir)); if !on { return Ok(json!({"enabled": false})); } match dir { Some(dir) if !cfg!(target_arch = "wasm32") => { let (files, errors) = generate_assets(&d.doc, &dir); Ok(json!({"enabled": true, "dir": dir, "files": files, "errors": errors})) } _ => Ok(json!({"enabled": true, "dir": null, "files": [], "note": "assets are written when the document is saved"})), } } /// After a save: regenerate the document's image assets when Generate › Image Assets is on. pub fn on_saved(s: &Session, index: usize) -> Option { let d = s.documents().get(index)?; if !s.file_menu.image_assets.contains(&d.doc.id) || cfg!(target_arch = "wasm32") { return None; } let dir = assets_dir(d.path.as_deref()?); let (files, errors) = generate_assets(&d.doc, &dir); Some(json!({"dir": dir, "files": files, "errors": errors})) } pub fn specs() -> Vec { macro_rules! spec { ($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } vec![ spec!( "file.export.saveForWebLegacy", "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), spec!( "file.export.exportPreferences", "Export Preferences…", &["File", "Export"], None, r##"{"quickExportFormat":"png|jpg|gif|webp|exr"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, |_| Ok(()), export_preferences ), spec!( "file.export.quickExport", "Quick Export", &[], None, r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"##, native_doc, quick_export ), spec!( "file.generate.imageAssets", "Image Assets", &["File", "Generate"], None, r##"{"on":bool? (default: toggle),"dir":folder? (default -assets next to the file)} → {enabled, files, errors}; layers named like "foo.png", "200% foo@2x.png", "48x48 icons/a.png8", "photo.jpg80%" are exported, now and after each save"##, has_doc, image_assets ), ] } #[cfg(test)] #[path = "web_cmds/tests.rs"] mod tests;