mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user