Merge pull request #111 from storytold/pr/m4-save-for-web
M4.90: Save for Web (Legacy)
This commit is contained in:
commit
39a7af7eba
|
|
@ -125,8 +125,8 @@ fn polygon(path: &kurbo::BezPath, tolerance: f64) -> Option<Vec<kurbo::Point>> {
|
||||||
(pts.len() >= 3).then_some(pts)
|
(pts.len() >= 3).then_some(pts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape text for an HTML attribute.
|
/// Escape text for an HTML attribute (or element text).
|
||||||
fn attr(s: &str) -> String {
|
pub(crate) fn attr(s: &str) -> String {
|
||||||
s.chars().filter(|c| !c.is_control()).fold(String::with_capacity(s.len()), |mut o, c| {
|
s.chars().filter(|c| !c.is_control()).fold(String::with_capacity(s.len()), |mut o, c| {
|
||||||
match c {
|
match c {
|
||||||
'&' => o.push_str("&"),
|
'&' => o.push_str("&"),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ pub mod dxfimport;
|
||||||
mod encode;
|
mod encode;
|
||||||
pub mod eps;
|
pub mod eps;
|
||||||
mod export;
|
mod export;
|
||||||
mod imagemap;
|
pub(crate) mod imagemap;
|
||||||
mod load;
|
mod load;
|
||||||
mod metafile;
|
mod metafile;
|
||||||
mod native;
|
mod native;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ pub(crate) mod typecmd;
|
||||||
mod typemenu;
|
mod typemenu;
|
||||||
pub(crate) mod views;
|
pub(crate) mod views;
|
||||||
pub mod wand;
|
pub mod wand;
|
||||||
|
pub mod webexport;
|
||||||
mod xform;
|
mod xform;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
@ -233,6 +234,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
v.extend(print::specs());
|
v.extend(print::specs());
|
||||||
v.extend(recovery::specs());
|
v.extend(recovery::specs());
|
||||||
v.extend(fileio::dxfimport::specs());
|
v.extend(fileio::dxfimport::specs());
|
||||||
|
v.extend(webexport::specs());
|
||||||
v
|
v
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ fn selected(s: &Session) -> Result<Vec<NodeId>> {
|
||||||
Ok(selected_slices(&st.doc, &st.selection))
|
Ok(selected_slices(&st.doc, &st.selection))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec<NodeId> {
|
pub(crate) fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec<NodeId> {
|
||||||
let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some()));
|
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())
|
dedup(sel.slices.iter().chain(objects).copied().collect())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -458,6 +458,13 @@ pub struct Prefs {
|
||||||
/// File Handling → Use Compression: native saves are gzip-compressed (`document.save
|
/// File Handling → Use Compression: native saves are gzip-compressed (`document.save
|
||||||
/// {compress}` overrides it).
|
/// {compress}` overrides it).
|
||||||
pub use_compression: bool,
|
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<cmd::webexport::WebPreset>,
|
||||||
|
/// 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<cmd::webexport::WebSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Prefs {
|
impl Default for Prefs {
|
||||||
|
|
@ -587,6 +594,8 @@ impl Default for Prefs {
|
||||||
templates_folder: String::new(),
|
templates_folder: String::new(),
|
||||||
append_converted: true,
|
append_converted: true,
|
||||||
use_compression: false,
|
use_compression: false,
|
||||||
|
web_export_presets: vec![],
|
||||||
|
web_export_settings: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1245,6 +1254,8 @@ mod tests_transparencygrid;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_units;
|
mod tests_units;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_webexport;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_widthpoints;
|
mod tests_widthpoints;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_widthprofiles;
|
mod tests_widthprofiles;
|
||||||
|
|
|
||||||
|
|
@ -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<u8>)> {
|
||||||
|
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<u8> {
|
||||||
|
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::<std::collections::HashSet<_>>().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transparent index of a GIF's graphic control extension, if it has one.
|
||||||
|
fn gif_transparent_index(gif: &[u8]) -> Option<u8> {
|
||||||
|
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<usize> = [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("<img ").count(), n);
|
||||||
|
assert!(html.contains("<a href=\"https://example.com/\" target=\"_blank\""));
|
||||||
|
assert!(html.contains("alt=\"Logo <1>\""));
|
||||||
|
assert!(html.contains("width: 300px; height: 200px;"));
|
||||||
|
// Each slice image has its slice's pixel size.
|
||||||
|
let logo = f.iter().find(|(name, _)| name == "images/logo.gif").unwrap();
|
||||||
|
assert_eq!(pixels(&logo.1).dimensions(), (100, 60));
|
||||||
|
// The images add up to the whole: every pixel is in one slice.
|
||||||
|
let area: u64 = f[1..].iter().map(|(_, b)| pixels(b).dimensions()).map(|(w, h)| u64::from(w) * u64::from(h)).sum();
|
||||||
|
assert_eq!(area, 300 * 200);
|
||||||
|
|
||||||
|
// Images only: no page.
|
||||||
|
assert_eq!(files(&run(&mut s, "document.exportForWeb", json!({}))).len(), n);
|
||||||
|
// A No Image slice is text on the page, not a file.
|
||||||
|
run(&mut s, "object.slice.select", json!({"slices": [b]}));
|
||||||
|
run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "<b>Hi</b>", "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("<b>Hi</b>") && 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::<Vec<_>>(), ["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<Value> { 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::<cmd::webexport::WebSettings>(custom.to_json()).unwrap(), custom);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
|
//! 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
|
//! ([`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
|
//! 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 bmp;
|
||||||
pub mod gif;
|
pub mod gif;
|
||||||
|
|
@ -11,6 +12,7 @@ pub mod png;
|
||||||
pub mod quantize;
|
pub mod quantize;
|
||||||
pub mod tga;
|
pub mod tga;
|
||||||
pub mod tiff;
|
pub mod tiff;
|
||||||
|
pub mod web;
|
||||||
|
|
||||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||||
use vectorcraft_geom::Rect;
|
use vectorcraft_geom::Rect;
|
||||||
|
|
@ -200,3 +202,5 @@ mod tests_palette;
|
||||||
mod tests_tga;
|
mod tests_tga;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_tiff;
|
mod tests_tiff;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_web;
|
||||||
|
|
|
||||||
|
|
@ -135,38 +135,54 @@ fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8;
|
||||||
Ok(out)
|
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
|
/// `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
|
/// 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.
|
/// characters (others are skipped). Unchanged when it doesn't start with a PNG header.
|
||||||
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
||||||
// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
|
let chunks: Vec<([u8; 4], Vec<u8>)> = entries
|
||||||
const HEADER: usize = 8 + 12 + 13;
|
.iter()
|
||||||
if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
|
.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<Vec<u8>> = 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<u8>) -> Vec<u8> {
|
||||||
|
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<u8>, chunks: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||||
|
if chunks.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
|
||||||
return png;
|
return png;
|
||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity(png.len() + entries.iter().map(|(k, v)| k.len() + v.len() + 20).sum::<usize>());
|
let mut out = Vec::with_capacity(png.len() + chunks.iter().map(|(_, d)| d.len() + 12).sum::<usize>());
|
||||||
out.extend_from_slice(&png[..HEADER]);
|
out.extend_from_slice(&png[..HEADER]);
|
||||||
for (keyword, text) in entries {
|
for (ty, data) in chunks {
|
||||||
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<Vec<u8>> = 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"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if u32::try_from(data.len()).is_ok() {
|
if u32::try_from(data.len()).is_ok() {
|
||||||
chunk(&mut out, ty, &data);
|
chunk(&mut out, ty, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.extend_from_slice(&png[HEADER..]);
|
out.extend_from_slice(&png[HEADER..]);
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,13 @@ pub struct Indexed {
|
||||||
|
|
||||||
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
|
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
|
||||||
pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed {
|
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 px = rgba.as_chunks::<4>().0;
|
||||||
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
|
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
|
||||||
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
|
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 has_clear = px.iter().any(clear);
|
||||||
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
|
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
|
||||||
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
|
let 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 {
|
let (mut palette, exact) = match o.reduction {
|
||||||
Reduction::BlackWhite => (vec![[0; 3], [255; 3]], false),
|
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::Gray => ((0..free).map(|i| [(i * 255 / (free - 1).max(1)) as u8; 3]).collect(), false),
|
||||||
Reduction::Web => (web(opaque(), k), false),
|
Reduction::Web => (web(opaque(), free), false),
|
||||||
r => match distinct(opaque(), k) {
|
r => match distinct(opaque(), k) {
|
||||||
Some(exact) => (exact, true),
|
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() {
|
if palette.is_empty() {
|
||||||
palette.push(matte);
|
palette.push(matte);
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +242,7 @@ fn distinct(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Option<Vec<[u8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A web-safe colour: each channel a multiple of 51.
|
/// A web-safe colour: each channel a multiple of 51.
|
||||||
fn web_safe(c: [u8; 3]) -> [u8; 3] {
|
pub fn web_safe(c: [u8; 3]) -> [u8; 3] {
|
||||||
c.map(|v| ((v as u32 + 25) / 51 * 51) as u8)
|
c.map(|v| ((v as u32 + 25) / 51 * 51) as u8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<u8> {
|
||||||
|
(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<u8> = 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);
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
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<Option<u8>> {
|
||||||
|
let n = ix.palette.len();
|
||||||
|
let gone: Vec<bool> = (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<usize> {
|
||||||
|
let n = ix.palette.len();
|
||||||
|
let identity: Vec<usize> = (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<usize> = 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<usize> = 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<u8>, text: &str) -> Vec<u8> {
|
||||||
|
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<u8>, text: &str) -> Vec<u8> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -564,7 +564,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
|
||||||
|
|
||||||
/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per
|
/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per
|
||||||
/// look, and its cell size in screen points.
|
/// look, and its cell size in screen points.
|
||||||
fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (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 colors = setup.grid_colors.map(|c| crate::panels::c32(&c));
|
||||||
let id = egui::Id::new("transparency-grid-tile");
|
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);
|
let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ pub mod recolor;
|
||||||
mod recovery;
|
mod recovery;
|
||||||
pub mod saturate;
|
pub mod saturate;
|
||||||
mod save_changes;
|
mod save_changes;
|
||||||
|
pub mod save_for_web;
|
||||||
pub(crate) mod save_options;
|
pub(crate) mod save_options;
|
||||||
mod save_pdf;
|
mod save_pdf;
|
||||||
pub mod save_style_library;
|
pub mod save_style_library;
|
||||||
|
|
@ -229,6 +230,7 @@ registry! {
|
||||||
Recovery: [crate::recovery::KIND] => recovery::SPEC,
|
Recovery: [crate::recovery::KIND] => recovery::SPEC,
|
||||||
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
|
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
|
||||||
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::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).
|
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||||
|
|
@ -352,3 +354,6 @@ mod tests_metafile;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_tiff_bmp_tga;
|
mod tests_tiff_bmp_tga;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_save_for_web;
|
||||||
|
|
|
||||||
|
|
@ -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<Value, String> {
|
||||||
|
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<String, Value> {
|
||||||
|
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<WebSettings, String> {
|
||||||
|
app.session.web_settings(¶ms(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||||
|
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<Value, String> {
|
||||||
|
// 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<Value, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<usize>,
|
||||||
|
colors: Arc<Vec<TableColor>>,
|
||||||
|
mapped: Arc<Vec<[u8; 3]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Result<Arc<WebRender>, String>>,
|
||||||
|
original: Option<Shot>,
|
||||||
|
shots: Vec<(String, Result<Shot, String>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Shot, String>, Vec<Result<Shot, String>>) {
|
||||||
|
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<Shot, String> {
|
||||||
|
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<WebSettings, String>) -> 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<WebSettings> = 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::<f32>().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<String> = 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<egui::Rect> {
|
||||||
|
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<Shot, String>,
|
||||||
|
settings: Option<&WebSettings>,
|
||||||
|
zoom: Option<f32>,
|
||||||
|
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<T: Copy>(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<Cache> = 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::<u32>() > 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<Value> = 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RefCell<Vec<String>>>;
|
||||||
|
|
||||||
|
/// 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"}));
|
||||||
|
}
|
||||||
|
|
@ -478,7 +478,7 @@ pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result<Value, Stri
|
||||||
|
|
||||||
/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and
|
/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and
|
||||||
/// `/-._~:` percent-encoded).
|
/// `/-._~:` percent-encoded).
|
||||||
fn file_url(path: &str) -> 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 = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned());
|
||||||
let abs = abs.replace('\\', "/");
|
let abs = abs.replace('\\', "/");
|
||||||
let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" });
|
let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" });
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"{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).
|
/// 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<R
|
||||||
crate::dialogs::eps_options::open(app, p);
|
crate::dialogs::eps_options::open(app, p);
|
||||||
Ok(Value::Null)
|
Ok(Value::Null)
|
||||||
}
|
}
|
||||||
|
// Save for Web: its dialog; with settings, write the files.
|
||||||
|
"file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => 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,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(r)
|
Some(r)
|
||||||
|
|
@ -1089,6 +1105,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||||
"ui.placementOptionsDialog" => selected_image(app, |_| true),
|
"ui.placementOptionsDialog" => selected_image(app, |_| true),
|
||||||
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
|
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
|
||||||
"ui.epsOptionsDialog" => app.session.active().is_some(),
|
"ui.epsOptionsDialog" => app.session.active().is_some(),
|
||||||
|
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1149,7 +1166,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||||
c("Export As…", "file.exportAs"),
|
c("Export As…", "file.exportAs"),
|
||||||
c("Export As SVG…", "file.export.svg"),
|
c("Export As SVG…", "file.export.svg"),
|
||||||
c("Export As PNG…", "file.export.png"),
|
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"),
|
c("Export Selection…", "document.exportSelection"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
(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
|
rows off; RLE turns flipped rows off). `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with
|
||||||
`document.export`.
|
`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).
|
||||||
|
|
|
||||||
30
docs/mcp.md
30
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":"run_command","arguments":{"command":"document.export","params":{"format":"bmp","depth":8,"rle":true}}}
|
||||||
{"name":"export","arguments":{"path":"/tmp/sprite.tga","options":{"depth":32}}}
|
{"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/<slice name>.<ext>` (`slices: selected` writes the selected ones, `none` one image); `output: html` adds
|
||||||
|
`<stem>.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}}}
|
||||||
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue