Save for Web: saving and Preview in Browser warn when an image couldn't be decoded

document.exportForWeb renders the artboard or the art and writes GIF,
JPEG, PNG-8 or PNG-24 files. When the art held an image that the
renderer could not decode, the renderer drew nothing for it, and the
files lacked the image with no message: the reply had no warnings at
all. A raster export of the same art warns that "an image couldn't be
decoded and is missing from the picture".

document.exportForWeb and document.exportForWeb.preview now compare
decode_failures before and after they render, as raster exports do,
and their replies carry that text in `warnings`. The files are still
written. Save, Download and Preview in Browser in the Save for Web
dialog show the first warning in the status bar as Save As shows a
save's notes.

Docs: docs/mcp.md (Save for Web), docs/control-protocol.md.
This commit is contained in:
dotCooCoo
2026-10-18 01:32:01 +07:00
committed by bflatastic
parent f5d61f6f27
commit a03583fc73
6 changed files with 112 additions and 15 deletions
+16 -5
View File
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web",
[],
None,
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height, warnings: [] or [a note that an image couldn't be decoded and is missing from the image]}; no path → {files: [{name, dataBase64}], bytes, width, height, warnings} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
has_doc,
export
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web Preview",
[],
None,
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, dataBase64?}",
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, warnings (as document.exportForWeb's), dataBase64?}",
has_doc,
preview
),
@@ -973,11 +973,13 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
None => super::fileio::safe_file_name(&super::fileio::file_stem(&st.doc.title)),
};
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
let failed = vectorcraft_render::decode_failures();
let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?;
let warnings = undecoded(failed);
let total: usize = files.iter().map(|f| f.bytes.len()).sum();
let Some(path) = path else {
let rows: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "dataBase64": vectorcraft_format::base64_encode(&f.bytes) })).collect();
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height }));
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height, "warnings": warnings }));
};
// Siblings keep the path's own separators.
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
@@ -991,7 +993,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::fileio::write_file(&target, &f.bytes)?;
written.push(target);
}
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height }))
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height, "warnings": warnings }))
}
/// The warnings of a rendering that started when this thread's decode failure count was `failed`:
/// the note the raster exports add (`fileio::encode::encode_all_of`) when an image has failed to
/// decode since then and so is missing from the image.
fn undecoded(failed: u64) -> Vec<&'static str> {
if vectorcraft_render::decode_failures() > failed { vec!["an image couldn't be decoded and is missing from the picture"] } else { vec![] }
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1003,7 +1012,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
}
let st = s.doc()?;
let doc = &st.doc;
let failed = vectorcraft_render::decode_failures();
let r = render(doc, &settings).map_err(|e| bad(P, e))?;
let warnings = undecoded(failed);
let areas = doc.slice_layout();
let selected = super::slices::selected_slices(doc, &st.selection);
let cells = cells(&areas, &selected, &settings, &r).map_err(|e| bad(P, e))?;
@@ -1019,7 +1030,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
.map_err(|e| bad(P, e))?;
let mut out = json!({
"format": settings.format.id(), "width": o.width, "height": o.height, "bytes": o.bytes.len(),
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images,
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images, "warnings": warnings,
});
if settings.format.palette() {
out["colors"] = o
+34
View File
@@ -308,6 +308,40 @@ fn files_are_written_beside_the_path() {
assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len());
}
/// A 64×32 PNG whose header reads (so it places at its size) but whose pixel data is cut short.
fn cut_png() -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
png
}
#[test]
fn an_export_says_when_an_image_could_not_be_decoded() {
let undecoded =
|r: &Value| r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))));
let mut s = two_colours();
let r = run(&mut s, "document.exportForWeb", json!({}));
assert_eq!(r["warnings"], json!([]), "{r}");
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["warnings"], json!([]));
// The renderer can't decode the placed image and draws nothing in its place.
run(&mut s, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut_png())}));
for format in ["gif", "jpg", "png8", "png24"] {
let r = run(&mut s, "document.exportForWeb", json!({"format": format}));
assert!(undecoded(&r), "{format}: {}", r["warnings"]);
let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format}));
assert!(undecoded(&r), "{format} preview: {}", r["warnings"]);
}
let dir = vectorcraft_testkit::temp_dir("webexport-undecoded");
let page = dir.join("page.html").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"}));
assert!(undecoded(&r), "{r}");
assert!(dir.join("images").join("page.gif").exists(), "the files are still written");
}
#[test]
fn image_size_region_and_metadata() {
let mut s = two_colours();
+25 -7
View File
@@ -131,7 +131,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
/// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML
/// output, else the image); the web downloads them.
/// output, else the image); the web downloads them. The status bar shows the export's first
/// warning.
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)?;
@@ -145,8 +146,11 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
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"] }));
match names.first().and_then(Value::as_str).filter(|_| has_warnings(&r)) {
Some(first) => io::report_saved(app, first, &r),
None => app.status(format!("Downloaded {} file(s)", names.len())),
}
return Ok(json!({ "files": names, "bytes": r["bytes"], "warnings": r["warnings"] }));
}
let ext = if s.output == WebOutput::Html { "html" } else { s.format.ext() };
let path = io::target_path(app, path, ext)?;
@@ -155,12 +159,23 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
}
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}") });
if n > 1 && !has_warnings(&r) {
app.status(format!("Saved {n} files beside {path}"));
} else {
io::report_saved(app, &path, &r);
}
Ok(r)
}
/// Whether an export's reply `r` has warnings (an image that couldn't be decoded):
/// [`io::report_saved`] puts the first in the status bar as a save's note.
fn has_warnings(r: &Value) -> bool {
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
}
/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the
/// default browser (desktop).
/// default browser (desktop). When the export has warnings, the status bar shows the first in
/// place of "Opened …".
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());
@@ -173,9 +188,12 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Val
o.insert("path".into(), json!(path));
o.insert("output".into(), json!("html"));
}
app.run("document.exportForWeb", q)?;
let r = app.run("document.exportForWeb", q)?;
app.open_url(&io::file_url(&path));
Ok(json!({ "path": path }))
if has_warnings(&r) {
io::report_saved(app, &path, &r);
}
Ok(json!({ "path": path, "warnings": r["warnings"] }))
}
/// The folder browser previews go to.
@@ -141,6 +141,38 @@ fn preview_in_browser_opens_the_page() {
assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}");
}
#[test]
fn saving_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
// A PNG whose header reads but whose pixel data is cut short: the renderer draws nothing for it.
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
let place = |app: &mut VectorcraftApp| {
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [30, 20]})).unwrap()
};
let dir = vectorcraft_testkit::temp_dir("sfw-undecoded");
let (mut desktop, _) = app(&dir, false);
desktop.run("file.saveForWeb", json!({"format": "png24"})).unwrap();
assert!(!desktop.ui.status.contains("note"), "{}", desktop.ui.status);
place(&mut desktop);
for p in [json!({"format": "png24"}), json!({"format": "jpg", "output": "html"})] {
desktop.status("");
desktop.run("file.saveForWeb", p.clone()).unwrap();
assert!(desktop.ui.status.starts_with("Saved ") && desktop.ui.status.ends_with(NOTE), "{p}: {}", desktop.ui.status);
}
desktop.status("");
desktop.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap();
assert!(desktop.ui.status.ends_with(NOTE), "Preview in Browser: {}", desktop.ui.status);
let (mut web, _) = app(&dir, true);
place(&mut web);
web.run("file.saveForWeb", json!({"format": "png8"})).unwrap();
assert!(web.ui.status.ends_with(NOTE), "download: {}", web.ui.status);
}
#[test]
fn colour_table_buttons_edit_the_lists() {
let mut d = Dialog::new(save_for_web::KIND, json!({}));
+2 -1
View File
@@ -675,7 +675,8 @@ a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit`
`__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).
writes the HTML page to a temporary folder and opens it in the default browser (desktop). Both return the export's
`warnings`, and the status bar shows the first one as Save As shows a save's notes.
PSD Options: Export As… with the PSD format (`file.exportAs {format: "psd"}`, then the save dialog) opens `psdOptions`:
the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK
+3 -2
View File
@@ -3137,13 +3137,14 @@ a Windows device name. With slices (`slice.list`) each image slice becomes `imag
after a Windows device name such as `aux` (`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}`.
Without `path` the files come back as `{name, dataBase64}`. As in the raster exports, `warnings` says when an image
couldn't be decoded and is missing from the image; the files are written all the same.
`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.
previews one slice; `image: true` adds the file; `warnings` holds the export's warnings.
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