From a12f5b0f2029431b093f6bc94f88c26b6bbf592f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 08:32:33 -0700 Subject: [PATCH] Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save for Office Documents, the pictures that css.export rasterizes and the PNG and PDF that Copy offers other apps were encoded with fileio::encode, which drops the encoder's warnings. When the art held an image that the renderer could not decode, the PNGs lacked it; type in a font that is not installed came out in the fallback font in the Office PNG and Copy's PNG and PDF. No reply or status message said so. The app also dropped the warnings that document.exportSelection returns. document.exportForOffice, clipboard.exportPng and clipboard.exportPdf now return `warnings`, and css.export adds the notes of its pictures to its `warnings`, each once. Save for Office Documents and document.exportSelection show the first note in the status bar in Save As's message, and the CSS Properties panel's Export Selected CSS… and Export All… show it in Export As's message. After Copy or Cut, once the PNG and PDF are on the system clipboard, the status bar shows their first note. The files are written as before. Docs: docs/mcp.md (Clipboard, CSS Properties), docs/control-protocol.md. --- crates/engine/src/cmd/clipboard/flavours.rs | 67 +++++++++++++------ crates/engine/src/cmd/css.rs | 19 ++++-- crates/engine/src/cmd/fileio/export.rs | 9 ++- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/fileio/tests_text.rs | 16 +++++ crates/engine/src/tests_clipflavours.rs | 32 +++++++++ crates/engine/src/tests_css.rs | 22 ++++++ .../src/dialogs/tests_raster_formats.rs | 22 ++++++ crates/ui-egui/src/io.rs | 6 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/css_properties.rs | 13 +++- .../src/panels/tests_css_properties.rs | 20 ++++++ crates/ui-egui/src/sysclip.rs | 26 +++++-- crates/ui-egui/src/tests_sysclip.rs | 29 +++++++- docs/control-protocol.md | 7 +- docs/mcp.md | 10 ++- 16 files changed, 247 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index b77df2017..50a2417a5 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -130,7 +130,7 @@ pub fn specs() -> Vec { "Clipboard as PNG", [], None, - "{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)", + "{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px), warnings} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty); warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)", always, export_png ), @@ -139,7 +139,7 @@ pub fn specs() -> Vec { "Clipboard as PDF", [], None, - "{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)", + "{} → {dataBase64, warnings} a one-page PDF of the copied objects, the page their visual bounds (dataBase64 null when the clipboard is empty); warnings as for document.export", always, export_pdf ), @@ -218,6 +218,9 @@ fn type_only(n: &Node) -> bool { } } +/// A PNG of the copied objects: (bytes, width, height in pixels, the encoder's warnings). +pub type Png = (Vec, u32, u32, Vec); + impl Clipboard { /// The copied type objects' text, one object per line (`None` unless it holds type only). pub fn text(&self) -> Option { @@ -243,21 +246,21 @@ impl Clipboard { Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b)) } - /// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when - /// there are none. - pub fn png(&self, scale: f64) -> Result, u32, u32)>> { + /// A PNG of the objects at `scale` pixels per point → (bytes, width, height, the warnings + /// [`fileio::encode_with_warnings`] gives); `None` when there are none. + pub fn png(&self, scale: f64) -> Result> { const C: &str = "clipboard.exportPng"; let Some((doc, b)) = self.cropped() else { return Ok(None) }; let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?; - let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?; - Ok(Some((png, w, h))) + let (png, warnings) = fileio::encode_with_warnings(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?; + Ok(Some((png, w, h, warnings))) } - /// A one-page PDF of the objects (without the native document inside); `None` when there are - /// none. - pub fn pdf(&self) -> Result>> { + /// A one-page PDF of the objects (without the native document inside) → (bytes, the encoder's + /// warnings); `None` when there are none. + pub fn pdf(&self) -> Result, Vec)>> { let Some((doc, _)) = self.cropped() else { return Ok(None) }; - fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some) + fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some) } /// The MIME types of [`Self::flavours`], without making them. @@ -275,23 +278,38 @@ impl Clipboard { /// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large /// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side. pub fn flavours(&self, svg: bool, pdf: bool) -> Vec { + self.flavours_with_warnings(svg, pdf).0 + } + + /// [`Self::flavours`], with the warnings of the PDF and PNG encoders, each once (an image that + /// couldn't be decoded, type in the fallback font). + pub fn flavours_with_warnings(&self, svg: bool, pdf: bool) -> (Vec, Vec) { let types = self.flavour_types(svg, pdf); let svg = types.contains(&SVG).then(|| self.svg()).flatten(); let mut out = vec![]; + let mut warnings: Vec = vec![]; + let mut keep = |(bytes, notes): (Vec, Vec)| { + for w in notes { + if !warnings.contains(&w) { + warnings.push(w); + } + } + bytes + }; for mime in types { let data = match mime { TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes), SVG => svg.clone().map(String::into_bytes), - PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(), + PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep), _ => { let side = self.bounds().map_or(1.0, |b| b.width().max(b.height())); let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0); - self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png) + self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes))) } }; out.extend(data.map(|data| Flavour { mime, data })); } - out + (out, warnings) } } @@ -302,15 +320,15 @@ impl Session { self.clipboard.text() } - /// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None` - /// when the clipboard is empty. - pub fn clipboard_png(&self, scale: f64) -> Result, u32, u32)>> { + /// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height, the + /// encoder's warnings); `None` when the clipboard is empty. + pub fn clipboard_png(&self, scale: f64) -> Result> { self.clipboard.png(scale) } - /// A one-page PDF of the copied objects (without the native document inside); `None` when the - /// clipboard is empty. - pub fn clipboard_pdf(&self) -> Result>> { + /// A one-page PDF of the copied objects (without the native document inside) → (bytes, the + /// encoder's warnings); `None` when the clipboard is empty. + pub fn clipboard_pdf(&self) -> Result, Vec)>> { self.clipboard.pdf() } @@ -356,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result { return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64")); } Ok(match s.clipboard_png(scale)? { - Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }), + Some((png, w, h, warnings)) => { + json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h, "warnings": warnings }) + } None => json!({ "dataBase64": null }), }) } fn export_pdf(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) })) + Ok(match s.clipboard_pdf()? { + Some((pdf, warnings)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&pdf), "warnings": warnings }), + None => json!({ "dataBase64": null }), + }) } fn export_text(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index 7384c9d0d..d31656b04 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode, isolated, write_file}; +use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; use super::*; pub fn specs() -> Vec { @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}", + "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -100,12 +100,19 @@ fn export(s: &mut Session, p: &Value) -> Result { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // The pictures of rasterized art. + // Encode the pictures of rasterized art, and add each of their warnings once. let mut images = Vec::new(); + let mut warnings = sheet.warnings.clone(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - images.push((name, encode(&d, "png", &json!({}))?)); + let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + images.push((name, png)); + for w in notes { + if !warnings.contains(&w) { + warnings.push(w); + } + } } let n = sheet.rules.len(); match str_param(p, "path") { @@ -118,12 +125,12 @@ fn export(s: &mut Session, p: &Value) -> Result { write_file(&at, bytes)?; written.push(at); } - Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings })) + Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": warnings })) } None => { let images: Vec = images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect(); - Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings })) + Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": warnings })) } } } diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 87751dc48..c768999a8 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -12,8 +12,7 @@ use crate::DocState; use super::super::*; use super::encode::single_artboard; use super::{ - ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, - write_or_return, + ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return, }; pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { @@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { } /// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless -/// `transparent`. +/// `transparent`, with the encoder's warnings. pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportForOffice"; if let Some(path) = str_param(p, "path") { @@ -242,8 +241,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { } let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" }; let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard })); - let bytes = encode(doc, "png", &opts)?; + let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?; // The pixel size, from the PNG header. let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes); - write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) })) + write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20), "warnings": warnings })) } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 7b1e170c9..dd3f54e7e 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -214,7 +214,7 @@ pub fn specs() -> Vec { "Save for Office Documents…", ["File"], None, - "{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}", + "{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)", has_doc, export::export_for_office ), diff --git a/crates/engine/src/cmd/fileio/tests_text.rs b/crates/engine/src/cmd/fileio/tests_text.rs index 9a41aead5..4ba819ff9 100644 --- a/crates/engine/src/cmd/fileio/tests_text.rs +++ b/crates/engine/src/cmd/fileio/tests_text.rs @@ -85,3 +85,19 @@ fn save_for_office_writes_a_png_at_the_resolution() { let spec = crate::cmd::find_command("document.exportForOffice").unwrap(); assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..])); } + +#[test] +fn save_for_office_says_when_an_image_or_a_font_is_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + assert_eq!(s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap()["warnings"], json!([])); + // The renderer can't decode the placed image, and the type's family isn't installed. + crate::tests_menucmds::place_undecodable_png(&mut s); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap(); + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!(warnings.contains(&"an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("fallback font") && w.contains("Nonexistent Sans Probe")), "{warnings:?}"); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)), "the PNG is still written"); +} diff --git a/crates/engine/src/tests_clipflavours.rs b/crates/engine/src/tests_clipflavours.rs index 7e727ec23..759f0f83a 100644 --- a/crates/engine/src/tests_clipflavours.rs +++ b/crates/engine/src/tests_clipflavours.rs @@ -97,6 +97,38 @@ fn exported_pdf_pastes_back_as_vectors() { assert!(t.execute("clipboard.importPdf", &json!({})).is_err()); } +#[test] +fn the_png_and_pdf_say_when_an_image_or_a_font_is_missing() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let warnings = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + let mut s = session(); + copy_red_square(&mut s); + assert_eq!(run(&mut s, "clipboard.exportPng", json!({}))["warnings"], json!([])); + assert_eq!(run(&mut s, "clipboard.exportPdf", json!({}))["warnings"], json!([])); + // Type in a family that isn't installed: the PNG and the PDF each name it. + let text = run(&mut s, "text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [text]})); + run(&mut s, "edit.copy", json!({})); + let png = warnings(&run(&mut s, "clipboard.exportPng", json!({}))); + assert!(png.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{png:?}"); + let pdf = warnings(&run(&mut s, "clipboard.exportPdf", json!({}))); + assert!(pdf.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{pdf:?}"); + // Copy's formats carry the notes of both, each once. + let (flavours, notes) = s.clipboard.flavours_with_warnings(false, true); + assert_eq!(flavours.iter().map(|f| f.mime).collect::>(), [TEXT, PDF, PNG]); + for w in png.iter().chain(&pdf) { + assert_eq!(notes.iter().filter(|n| *n == w).count(), 1, "{w} in {notes:?}"); + } + // An image the renderer can't decode: the PNG leaves it out and says so. + let image = crate::tests_menucmds::place_undecodable_png(&mut s); + run(&mut s, "select.set", json!({"ids": [image.0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(warnings(&run(&mut s, "clipboard.exportPng", json!({}))), [NOTE]); + let (flavours, notes) = s.clipboard.flavours_with_warnings(false, false); + assert_eq!(flavours.iter().map(|f| f.mime).collect::>(), [PNG]); + assert_eq!(notes, [NOTE]); +} + #[test] fn import_image_then_paste_gives_an_image() { let mut s = session(); diff --git a/crates/engine/src/tests_css.rs b/crates/engine/src/tests_css.rs index e62e243a9..24976e1e6 100644 --- a/crates/engine/src/tests_css.rs +++ b/crates/engine/src/tests_css.rs @@ -115,3 +115,25 @@ fn export_to_a_path_puts_the_pictures_next_to_the_sheet() { assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG")); std::fs::remove_dir_all(&dir).ok(); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_says_once_when_a_rasterized_image_could_not_be_decoded() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let notes = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str() == Some(NOTE)).count(); + let mut s = session(); + run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20})); + assert_eq!(notes(&run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}))), 0); + // Two images the renderer can't decode, each rasterized to a picture of its own. + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png(&mut s); + let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true})); + assert_eq!(r["images"].as_array().unwrap().len(), 3, "{r}"); + assert_eq!(notes(&r), 1, "{}", r["warnings"]); + let dir = std::env::temp_dir().join(format!("vc-css-undecoded-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = run(&mut s, "css.export", json!({"path": dir.join("site.css").to_string_lossy(), "scope": "all", "rasterize": true})); + assert_eq!(r["images"].as_array().unwrap().len(), 3, "the pictures are still written"); + assert_eq!(notes(&r), 1, "{}", r["warnings"]); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/ui-egui/src/dialogs/tests_raster_formats.rs b/crates/ui-egui/src/dialogs/tests_raster_formats.rs index dcef576a2..15fa77fda 100644 --- a/crates/ui-egui/src/dialogs/tests_raster_formats.rs +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -133,3 +133,25 @@ fn save_for_office_documents_picks_the_resolution() { let img = image::load_from_memory(&written.borrow()[1].1).unwrap().to_rgba8(); assert_eq!((img.dimensions(), img.get_pixel(0, 0).0), ((60, 40), [255, 255, 255, 255])); } + +#[test] +fn save_for_office_and_export_selection_say_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"; + let (mut app, written) = app(1); + app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap(); + assert_eq!(app.ui.status, "Saved /x/o.png"); + // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. + 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); + app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [10, 10]})).unwrap(); + app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap(); + assert_eq!(app.ui.status, format!("Saved /x/o.png {NOTE}")); + // Export Selection of the placed image, which stays selected. + app.run("document.exportSelection", json!({"path": "/x/cut.png"})).unwrap(); + assert_eq!(app.ui.status, format!("Saved /x/cut.png {NOTE}")); + assert_eq!(written.borrow().len(), 3, "the files are still written"); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 77dd4220e..fa3e33b4c 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -718,10 +718,10 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option Result { - let (path, _) = run_to_file(app, id, ext, params)?; - app.status(format!("Saved {path}")); + let (path, v) = run_to_file(app, id, ext, params)?; + report_saved(app, &path, &v); Ok(path) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6a4e0889..747b7b12a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -824,7 +824,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…]}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", ), ( "file.print", diff --git a/crates/ui-egui/src/panels/css_properties.rs b/crates/ui-egui/src/panels/css_properties.rs index e6cd65818..5b0fe407a 100644 --- a/crates/ui-egui/src/panels/css_properties.rs +++ b/crates/ui-egui/src/panels/css_properties.rs @@ -186,7 +186,8 @@ pub(crate) fn copy(app: &mut VectorcraftApp, p: &Value) -> Result } /// `css.exportFile {path?, scope?, …css.selection options}`: `css.export` written to `path`, else -/// a picked file (the web downloads it), its pictures next to it → `{path, rules, images}`. +/// a picked file (the web downloads it), its pictures next to it → `{path, rules, images, +/// warnings}`. pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result { let mut q = p.clone(); let path = q.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string)); @@ -202,8 +203,14 @@ pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let what = &path; + app.status(match warnings.first() { + Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()), + None => format!("Exported {path}"), + }); + Ok(json!({ "path": path, "rules": r["rules"], "images": images, "warnings": r["warnings"] })) } /// The panel (≡) menu: copy and export, Generate CSS and the export options. diff --git a/crates/ui-egui/src/panels/tests_css_properties.rs b/crates/ui-egui/src/panels/tests_css_properties.rs index a73e1164e..41cb6c0ab 100644 --- a/crates/ui-egui/src/panels/tests_css_properties.rs +++ b/crates/ui-egui/src/panels/tests_css_properties.rs @@ -101,3 +101,23 @@ fn copy_and_export_go_through_the_ui_commands() { assert!(css.contains(".rectangle {") && css.contains("background-image: url(path.png);"), "{css}"); assert!(log[1].1.starts_with(b"\x89PNG")); } + +#[test] +fn export_says_in_the_status_bar_when_an_image_could_not_be_decoded() { + const WARNING: &str = "an image couldn't be decoded and is missing from the picture"; + let (mut app, log) = app(); + app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap(); + assert_eq!(app.ui.status, "Exported /tmp/site/site.css"); + // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. + 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); + app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap(); + assert_eq!(r["warnings"], json!([WARNING])); + assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 1 note(s): {WARNING}")); + assert_eq!(log.borrow().len(), 3, "the sheet and the image's picture are still written"); +} diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index 78a2a74d3..42aed4b4e 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -74,7 +74,8 @@ impl VectorcraftApp { /// ([`Self::drop_formats_after_egui_copy`]), drops the formats not written yet. While the /// latest Copy or Cut's formats wait to be written, Paste takes the internal clipboard, and /// while a text field or type has the keyboard the app waits for them - /// ([`Self::write_formats_before_text_input`]). + /// ([`Self::write_formats_before_text_input`]). Once they are written, the status bar shows + /// the first of the PNG's and PDF's warnings. pub(crate) fn publish_clipboard(&mut self) { if self.services.system_clipboard.is_none() { if self.session.prefs.copy_as_svg { @@ -92,7 +93,7 @@ impl VectorcraftApp { // These formats are not made when a later Copy or Cut, or a copy through egui, dropped them. if latest.load(Ordering::SeqCst) == n { // The receiver is gone only when the job was dropped unapplied. - let _ = tx.send(clip.flavours(svg, pdf)); + let _ = tx.send(clip.flavours_with_warnings(svg, pdf)); } Ok(Value::Null) }; @@ -102,16 +103,27 @@ impl VectorcraftApp { return Ok(Value::Null); } publishing.pending = false; - let flavours = r.and_then(|_| made.try_recv().map_err(|e| e.to_string())); - let written = flavours.and_then(|f| app.services.system_clipboard.as_mut().map_or(Ok(()), |cb| cb.write(&f))); - if let Err(e) = written { - app.ui.status = format!("Couldn't copy to the system clipboard: {e}"); + let formats = r.and_then(|_| made.try_recv().map_err(|e| e.to_string())); + let written = formats.and_then(|(f, warnings)| { + app.services.system_clipboard.as_mut().map_or(Ok(()), |cb| cb.write(&f))?; + Ok(warnings) + }); + match written { + Err(e) => app.ui.status = format!("Couldn't copy to the system clipboard: {e}"), + // Show the first of the PNG's and PDF's warnings (an image that couldn't be decoded, + // type in the fallback font). + Ok(warnings) => { + if let Some(first) = warnings.into_iter().next() { + app.ui.status = first; + } + } } Ok(Value::Null) }; // Only the formats of other copies (jobs without a label) may be ahead of these on the worker. let free = self.background.jobs.iter().all(|j| j.label.is_empty()); - // No label: the status bar doesn't show the job, and the status stays as it is. + // No label: the status bar doesn't show the job, and only a failure or a warning changes + // the status. if let Err(e) = background::run(self, free, String::new(), None, work, then) { self.ui.status = e; } diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests_sysclip.rs index 38ddbb012..272338683 100644 --- a/crates/ui-egui/src/tests_sysclip.rs +++ b/crates/ui-egui/src/tests_sysclip.rs @@ -243,6 +243,33 @@ fn a_copy_while_the_worker_saves_writes_the_formats_at_once() { assert_eq!(board.borrow().writes, 1); } +/// Copy of an image the renderer can't decode: the PNG leaves it out, and once the formats are +/// written the status bar shows the PNG's note, made at once or on the worker. +#[test] +fn copy_says_in_the_status_bar_when_an_image_could_not_be_decoded() { + const WARNING: &str = "an image couldn't be decoded and is missing from the picture"; + // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. + 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); + for worker in [false, true] { + let (mut app, board) = if worker { app_with_worker() } else { app() }; + app.status("Ready"); + copy_rect(&mut app); + background::wait_all(&mut app); + assert_eq!(app.ui.status, "Ready", "worker {worker}: no note"); + run(&mut app, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})); + app.status("Ready"); + run(&mut app, "edit.copy", json!({})); + background::wait_all(&mut app); + assert_eq!(written(&board), [TEXT, SVG, PNG], "worker {worker}"); + assert_eq!(app.ui.status, WARNING, "worker {worker}"); + } +} + /// Text copied in a field or with the Type tool after Copy, before the formats are written: they /// are not written, the system clipboard keeps the text, and Paste takes it. #[test] @@ -476,7 +503,7 @@ fn a_pdf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() { let id = other.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap()["id"].clone(); other.execute("select.set", &json!({"ids": [id]})).unwrap(); other.execute("edit.copy", &json!({})).unwrap(); - let pdf = other.clipboard_pdf().unwrap().unwrap(); + let (pdf, _) = other.clipboard_pdf().unwrap().unwrap(); copy_elsewhere(&board, vec![(PNG, blue_png()), (PDF, pdf)]); run(&mut app, "edit.paste", json!({})); let p = pasted(&app); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 563f72bca..5ee2e9c97 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -116,13 +116,13 @@ the two can still win that race. | `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | | `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | -| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` | +| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as assets (`assets.add`; one per object unless `multiple` is false) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | -| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images}` | +| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | @@ -572,7 +572,8 @@ dialog, a drop) opens the `textImport` dialog (fields `platform`: `windows`/`mac type with those options (`file.place {text: {…}}`). System clipboard formats: through the desktop app, `edit.copy` and `edit.cut` publish what `clipboard.flavours` -lists to the system clipboard, and every Paste command first loads what another app copied (`clipboard.importSvg`, +lists to the system clipboard (once they are written, the status bar shows the first of the PNG's and PDF's +`warnings`, such as an image that couldn't be decoded), and every Paste command first loads what another app copied (`clipboard.importSvg`, `importPdf`, `importEps`, `importText` or `importImage`, centred in the view; the desktop app reads PDF and EPS from the Windows clipboard and, with SVG, from the macOS pasteboard, and an `.eps` file, or a `.ai` or `.ait` file that holds a PDF or PostScript, copied in a file manager anywhere); an import error answers the Paste with that error and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no diff --git a/docs/mcp.md b/docs/mcp.md index e026708a5..e0e962bf9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2725,7 +2725,9 @@ preferences: `text/plain` (the text of a type-only copy, else the SVG markup wit area). Each one has a query command: `clipboard.exportSvg`, `clipboard.exportText` (null unless every copied object is type), `clipboard.exportPdf` (a one-page PDF, the page the objects' visual bounds) and `clipboard.exportPng {scale?}` (cropped to the objects, -transparent around them). +transparent around them). As in `document.export`, the PDF's and the PNG's replies list their notes in `warnings` +(type written in the fallback font…; the PNG's also say when an image couldn't be decoded). After Copy or Cut the app +shows the first note of the PDF and PNG it offers in the status bar. What other apps copied goes into the clipboard with `clipboard.importSvg`, `clipboard.importPdf {dataBase64, page?, password?}` (the page's objects), `clipboard.importEps {dataBase64}` (an EPS or other PostScript picture's objects, @@ -3178,8 +3180,10 @@ says when CSS can't describe the art exactly (`unsupported`: other shapes, image written as its box, or with `rasterize: true` as `background-image: url(.png)`. `units` is `px` (1 px per point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out unnamed objects (counted in `skipped`). `css.generate` does the same for the whole document, and `css.export {path?, scope?: selection|all}` -writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). In the app, -`css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it). +writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its +`warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the +fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web +downloads it) and shows the first note in the status bar. ```json {"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}}