M4.90: Save for Web (Legacy) previews and saves optimised GIF, JPEG, PNG-8 and PNG-24 images, slices and HTML
File > Export > Save for Web (Legacy) (Ctrl/Cmd+Alt+Shift+S) opens a dialog with Original, Optimized, 2-Up and 4-Up views showing each file's size and download time, and settings for colour reduction, colours, dither, transparency and matte, interlacing, web snap, lossy GIF, JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size and anti-aliasing, Clip to Artboard, sRGB, metadata, slices (all or selected), HTML output and presets. Preview in Browser opens the HTML page. Engine: document.exportForWeb, document.exportForWeb.preview (its byte count is the size of the file Save writes), webExport.settings and webExport.presets.list/save/delete. Saved presets and the remembered settings are stored in Prefs. Render: quantize_locked, and encode::web for web snap, colour table edits, lossy GIF and GIF/JPEG comments. PNG files get an sRGB chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b578c32f4d
commit
c6450f6f70
|
|
@ -125,8 +125,8 @@ fn polygon(path: &kurbo::BezPath, tolerance: f64) -> Option<Vec<kurbo::Point>> {
|
|||
(pts.len() >= 3).then_some(pts)
|
||||
}
|
||||
|
||||
/// Escape text for an HTML attribute.
|
||||
fn attr(s: &str) -> String {
|
||||
/// Escape text for an HTML attribute (or element text).
|
||||
pub(crate) fn attr(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_control()).fold(String::with_capacity(s.len()), |mut o, c| {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pub mod dxfimport;
|
|||
mod encode;
|
||||
pub mod eps;
|
||||
mod export;
|
||||
mod imagemap;
|
||||
pub(crate) mod imagemap;
|
||||
mod load;
|
||||
mod metafile;
|
||||
mod native;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ pub(crate) mod typecmd;
|
|||
mod typemenu;
|
||||
pub(crate) mod views;
|
||||
pub mod wand;
|
||||
pub mod webexport;
|
||||
mod xform;
|
||||
|
||||
use serde::Serialize;
|
||||
|
|
@ -233,6 +234,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(print::specs());
|
||||
v.extend(recovery::specs());
|
||||
v.extend(fileio::dxfimport::specs());
|
||||
v.extend(webexport::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ fn selected(s: &Session) -> Result<Vec<NodeId>> {
|
|||
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()));
|
||||
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
|
||||
/// {compress}` overrides it).
|
||||
pub use_compression: bool,
|
||||
/// Save for Web: the user's presets (the built-in ones aren't stored). A local library, not a
|
||||
/// Preferences dialog field: resetting the preferences keeps it; `webExport.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub web_export_presets: Vec<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 {
|
||||
|
|
@ -587,6 +594,8 @@ impl Default for Prefs {
|
|||
templates_folder: String::new(),
|
||||
append_converted: true,
|
||||
use_compression: false,
|
||||
web_export_presets: vec![],
|
||||
web_export_settings: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1245,6 +1254,8 @@ mod tests_transparencygrid;
|
|||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
#[cfg(test)]
|
||||
mod tests_widthpoints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
|
||||
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte
|
||||
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`].
|
||||
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]; [`web`] adds what Save for Web optimises
|
||||
//! further (web snap, colour table edits, lossy GIF, comments).
|
||||
|
||||
pub mod bmp;
|
||||
pub mod gif;
|
||||
|
|
@ -11,6 +12,7 @@ pub mod png;
|
|||
pub mod quantize;
|
||||
pub mod tga;
|
||||
pub mod tiff;
|
||||
pub mod web;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
|
@ -200,3 +202,5 @@ mod tests_palette;
|
|||
mod tests_tga;
|
||||
#[cfg(test)]
|
||||
mod tests_tiff;
|
||||
#[cfg(test)]
|
||||
mod tests_web;
|
||||
|
|
|
|||
|
|
@ -135,38 +135,54 @@ fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8;
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
|
||||
const HEADER: usize = 8 + 12 + 13;
|
||||
|
||||
/// `png` (a PNG file) with a text chunk per `(keyword, text)` right after its header: `tEXt`, or
|
||||
/// uncompressed `iTXt` (UTF-8) for text beyond Latin-1. Keywords are 1–79 printable ASCII
|
||||
/// characters (others are skipped). Unchanged when it doesn't start with a PNG header.
|
||||
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
||||
// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
|
||||
const HEADER: usize = 8 + 12 + 13;
|
||||
if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
|
||||
let chunks: Vec<([u8; 4], Vec<u8>)> = entries
|
||||
.iter()
|
||||
.filter(|(keyword, _)| !keyword.is_empty() && keyword.len() <= 79 && keyword.bytes().all(|b| (32..=126).contains(&b)))
|
||||
.map(|(keyword, text)| {
|
||||
let mut data = keyword.as_bytes().to_vec();
|
||||
data.push(0);
|
||||
let latin1: Option<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;
|
||||
}
|
||||
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]);
|
||||
for (keyword, text) in entries {
|
||||
if keyword.is_empty() || keyword.len() > 79 || !keyword.bytes().all(|b| (32..=126).contains(&b)) {
|
||||
continue;
|
||||
}
|
||||
let mut data = keyword.as_bytes().to_vec();
|
||||
data.push(0);
|
||||
let latin1: Option<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"
|
||||
}
|
||||
};
|
||||
for (ty, data) in chunks {
|
||||
if u32::try_from(data.len()).is_ok() {
|
||||
chunk(&mut out, ty, &data);
|
||||
chunk(&mut out, ty, data);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&png[HEADER..]);
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ pub struct Indexed {
|
|||
|
||||
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
|
||||
pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed {
|
||||
quantize_locked(rgba, width, height, o, &[])
|
||||
}
|
||||
|
||||
/// [`quantize`], keeping the `locked` colours in a reduced palette (the colour table's locked
|
||||
/// colours: reducing the colours further never drops them). Art with few enough colours keeps
|
||||
/// them all anyway; locked colours no pixel ends up using are left out.
|
||||
pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> Indexed {
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
|
||||
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
|
||||
|
|
@ -162,15 +169,29 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind
|
|||
let has_clear = px.iter().any(clear);
|
||||
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
|
||||
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
|
||||
// Locked colours take their slots first (at least one is left for the reduction).
|
||||
let mut lock: Vec<[u8; 3]> = Vec::with_capacity(locked.len().min(k));
|
||||
for c in locked {
|
||||
if lock.len() + 1 < k && !lock.contains(c) {
|
||||
lock.push(*c);
|
||||
}
|
||||
}
|
||||
let free = k - lock.len();
|
||||
let (mut palette, exact) = match o.reduction {
|
||||
Reduction::BlackWhite => (vec![[0; 3], [255; 3]], false),
|
||||
Reduction::Gray => ((0..k).map(|i| [(i * 255 / (k - 1).max(1)) as u8; 3]).collect(), false),
|
||||
Reduction::Web => (web(opaque(), k), false),
|
||||
Reduction::Gray => ((0..free).map(|i| [(i * 255 / (free - 1).max(1)) as u8; 3]).collect(), false),
|
||||
Reduction::Web => (web(opaque(), free), false),
|
||||
r => match distinct(opaque(), k) {
|
||||
Some(exact) => (exact, true),
|
||||
None => (median_cut(opaque(), k, r), false),
|
||||
None => (median_cut(opaque(), free, r), false),
|
||||
},
|
||||
};
|
||||
if !exact && !lock.is_empty() {
|
||||
palette.retain(|c| !lock.contains(c));
|
||||
lock.append(&mut palette);
|
||||
lock.truncate(k);
|
||||
palette = lock;
|
||||
}
|
||||
if palette.is_empty() {
|
||||
palette.push(matte);
|
||||
}
|
||||
|
|
@ -221,7 +242,7 @@ fn distinct(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Option<Vec<[u8;
|
|||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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 id = egui::Id::new("transparency-grid-tile");
|
||||
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;
|
||||
pub mod saturate;
|
||||
mod save_changes;
|
||||
pub mod save_for_web;
|
||||
pub(crate) mod save_options;
|
||||
mod save_pdf;
|
||||
pub mod save_style_library;
|
||||
|
|
@ -229,6 +230,7 @@ registry! {
|
|||
Recovery: [crate::recovery::KIND] => recovery::SPEC,
|
||||
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
|
||||
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC,
|
||||
SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
@ -352,3 +354,6 @@ mod tests_metafile;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests_tiff_bmp_tga;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_save_for_web;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// `/-._~:` 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 = abs.replace('\\', "/");
|
||||
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",
|
||||
),
|
||||
(
|
||||
"file.saveForWeb",
|
||||
"Save for Web (Legacy)…",
|
||||
"Cmd+Alt+Shift+S",
|
||||
"{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}",
|
||||
),
|
||||
(
|
||||
"file.saveForWeb.browser",
|
||||
"Preview in Browser",
|
||||
"",
|
||||
"{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -855,6 +867,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
crate::dialogs::eps_options::open(app, p);
|
||||
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,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -1089,6 +1105,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
"ui.placementOptionsDialog" => selected_image(app, |_| true),
|
||||
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
|
||||
"ui.epsOptionsDialog" => app.session.active().is_some(),
|
||||
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -1149,7 +1166,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("Export As…", "file.exportAs"),
|
||||
c("Export As SVG…", "file.export.svg"),
|
||||
c("Export As PNG…", "file.export.png"),
|
||||
todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S"),
|
||||
c("Save for Web (Legacy)…", "file.saveForWeb"),
|
||||
],
|
||||
),
|
||||
c("Export Selection…", "document.exportSelection"),
|
||||
|
|
|
|||
|
|
@ -374,3 +374,12 @@ the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster r
|
|||
(16, 24, 32). The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped
|
||||
rows off; RLE turns flipped rows off). `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with
|
||||
`document.export`.
|
||||
|
||||
Save for Web: File → Export → Save for Web (Legacy)… (`file.saveForWeb`, Ctrl/Cmd+Alt+Shift+S) opens the `saveForWeb`
|
||||
dialog on the remembered settings (`webExport.settings`). Its fields are the `document.exportForWeb` settings
|
||||
(`format`, `colors`, `reduction`, `dither`, `quality`, `colorTable`, `width`, `percent`, `output`…; set `preset` to load
|
||||
a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit` or a percentage), `__kbps` and
|
||||
`__color` (the colour table's selected colour). `ui.dialog.confirm` remembers the settings and saves (a save panel for
|
||||
the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers
|
||||
the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser`
|
||||
writes the HTML page to a temporary folder and opens it in the default browser (desktop).
|
||||
|
|
|
|||
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":"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