From a03583fc73aa2268d1779fe20ba21c50ce16aa4d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 01:47:01 -0700 Subject: [PATCH] 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. --- crates/engine/src/cmd/webexport.rs | 21 +++++++++--- crates/engine/src/tests_webexport.rs | 34 +++++++++++++++++++ crates/ui-egui/src/dialogs/save_for_web.rs | 32 +++++++++++++---- .../ui-egui/src/dialogs/tests_save_for_web.rs | 32 +++++++++++++++++ docs/control-protocol.md | 3 +- docs/mcp.md | 5 +-- 6 files changed, 112 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index 081205b09..30b4fd998 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "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/.), output?: images (default)|html (an HTML page placing the image or slices, .html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself", + "{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/.), output?: images (default)|html (an HTML page placing the image or slices, .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 { "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 { 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 = 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 { 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 { @@ -1003,7 +1012,9 @@ fn preview(s: &mut Session, p: &Value) -> Result { } 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 { .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 diff --git a/crates/engine/src/tests_webexport.rs b/crates/engine/src/tests_webexport.rs index fab5d7074..277c5e101 100644 --- a/crates/engine/src/tests_webexport.rs +++ b/crates/engine/src/tests_webexport.rs @@ -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 { + 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(); diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 9426fa866..c15532437 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -131,7 +131,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } /// `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 { // 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 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 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 { 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.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