Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing

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.
This commit is contained in:
dotCooCoo
2026-10-18 01:27:13 +07:00
committed by bflatastic
parent efb49169a3
commit a12f5b0f20
16 changed files with 247 additions and 55 deletions
+45 -22
View File
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<u8>, u32, u32, Vec<String>);
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
@@ -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<Option<(Vec<u8>, 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<Option<Png>> {
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<Option<Vec<u8>>> {
/// 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<Option<(Vec<u8>, Vec<String>)>> {
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<Flavour> {
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<Flavour>, Vec<String>) {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
let mut warnings: Vec<String> = vec![];
let mut keep = |(bytes, notes): (Vec<u8>, Vec<String>)| {
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<Option<(Vec<u8>, 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<Option<Png>> {
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<Option<Vec<u8>>> {
/// 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<Option<(Vec<u8>, Vec<String>)>> {
self.clipboard.pdf()
}
@@ -356,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
+13 -6
View File
@@ -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<CommandSpec> {
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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: <document>.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: <document>.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<Value> {
}
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<Value> {
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<Value> =
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 }))
}
}
}
+4 -5
View File
@@ -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<Value> {
@@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
/// 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<Value> {
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<Value> {
}
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 }))
}
+1 -1
View File
@@ -214,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
@@ -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");
}
+32
View File
@@ -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<String> { 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::<Vec<_>>(), [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::<Vec<_>>(), [PNG]);
assert_eq!(notes, [NOTE]);
}
#[test]
fn import_image_then_paste_gives_an_image() {
let mut s = session();
+22
View File
@@ -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();
}
@@ -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");
}
+3 -3
View File
@@ -718,10 +718,10 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs'
/// Export…).
/// Export…), reported with the first of the command's warnings ([`report_saved`]).
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result<String, String> {
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)
}
+1 -1
View File
@@ -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",
+10 -3
View File
@@ -186,7 +186,8 @@ pub(crate) fn copy(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String>
}
/// `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<Value, String> {
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<Value,
io::write_to(&mut app.services, &at, &bytes)?;
images.push(at);
}
app.status(format!("Exported {path}"));
Ok(json!({ "path": path, "rules": r["rules"], "images": images }))
// The status bar shows the first warning in Export As's message.
let warnings: Vec<&str> = 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.
@@ -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");
}
+19 -7
View File
@@ -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;
}
+28 -1
View File
@@ -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);
+4 -3
View File
@@ -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 `<stem>.html` (client-side `<map>`) or `<stem>.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
+7 -3
View File
@@ -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(<class>.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}}}