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}}}