mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #2762 from dotCooCoo/fix/exports-keep-encoder-warnings
Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}}}
|
||||
|
||||
Reference in New Issue
Block a user