mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
M4.13: review fixes — template sublayers are left out of raster exports too (the check moves into draw_arc), Export Selection skips art under a template layer at any depth, Export for Screens drops @Nx suffixes on SVG/PDF rows, names files without case clashes and calls an unnamed artboard Artboard-N, oversized raster exports are refused instead of writing empty files, a named MCP artboard/range replaces the option picks, and control app.export without a path returns dataBase64 instead of opening a save dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45a7302601
commit
7bc50901e4
@@ -12,6 +12,9 @@ use crate::EngineError;
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
/// The params that pick artboards (the fields of [`ArtboardPick`]).
|
||||
pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"];
|
||||
|
||||
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based) wins over `artboards`
|
||||
/// (0-based), which wins over `artboard`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -80,6 +83,28 @@ fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
T::deserialize(p).map_err(|e| bad(C, format!("{} options: {e}", f.label)))
|
||||
}
|
||||
|
||||
/// Most pixels a raster export renders (1 GiB of RGBA).
|
||||
const MAX_PIXELS: f64 = 268_435_456.0;
|
||||
|
||||
/// Refuse a raster export the encoder can't write or memory can't hold (instead of writing an
|
||||
/// empty file or aborting).
|
||||
fn check_pixels(f: &Format, w: f64, h: f64) -> Result<()> {
|
||||
// WebP stores sizes in 14 bits, JPEG in 16.
|
||||
let side = if f.id == "webp" { 16383.0 } else { 65535.0 };
|
||||
if w.round() > side || h.round() > side || w * h > MAX_PIXELS {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"{}×{} px is too large for {} (at most {side} px a side, {MAX_PIXELS} px in all): lower the scale",
|
||||
w.round(),
|
||||
h.round(),
|
||||
f.label
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out.
|
||||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
@@ -102,6 +127,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
let region = doc.artboards[boards(o.boards.one(n))?].rect;
|
||||
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
|
||||
check_pixels(f, region.width() * scale, region.height() * scale)?;
|
||||
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, f.id == "jpg");
|
||||
match f.id {
|
||||
"png" => img.to_png(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return};
|
||||
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
@@ -30,7 +30,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn without_artboards(p: &Value) -> Value {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
for k in ["artboard", "artboards", "range"] {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
o.remove(k);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable(C, str_param(p, "format"), path)?;
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let on_template = |id| st.doc.layer_of(id).and_then(|l| st.doc.node(l)).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
|
||||
let mut d = (*st.doc).clone();
|
||||
@@ -79,10 +81,15 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
||||
if format.id == "vectorcraft" {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix.
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 };
|
||||
let suffix =
|
||||
str_param(row, "suffix").map(str::to_string).unwrap_or_else(|| if (scale - 1.0).abs() < 1e-9 { String::new() } else { format!("@{scale}x") });
|
||||
let suffix = match str_param(row, "suffix") {
|
||||
Some(s) if !format.raster && is_scale_suffix(s) => String::new(),
|
||||
Some(s) => s.to_string(),
|
||||
None if (scale - 1.0).abs() < 1e-9 => String::new(),
|
||||
None => format!("@{scale}x"),
|
||||
};
|
||||
let mut options = without_artboards(row);
|
||||
if let Some(o) = options.as_object_mut() {
|
||||
o.insert("scale".into(), json!(scale));
|
||||
@@ -90,6 +97,11 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
||||
Ok(ScreenFormat { format, options, suffix })
|
||||
}
|
||||
|
||||
/// A pixel-density suffix such as `@2x` or `@0.5x`.
|
||||
fn is_scale_suffix(s: &str) -> bool {
|
||||
s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::<f64>().is_ok())
|
||||
}
|
||||
|
||||
/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds
|
||||
/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting.
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -107,21 +119,25 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
// Names are compared without case: `Icon` and `icon` are one file on most desktop file systems.
|
||||
let mut names = HashSet::new();
|
||||
let mut written = HashSet::new();
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = format!("Artboard-{}", b + 1);
|
||||
}
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if names.insert(name.clone()) {
|
||||
if names.insert(name.to_lowercase()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
}
|
||||
for sf in &formats {
|
||||
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
|
||||
if !written.insert(file.clone()) {
|
||||
if !written.insert(file.to_lowercase()) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let mut options = sf.options.clone();
|
||||
|
||||
@@ -17,7 +17,7 @@ mod save;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use encode::{ArtboardPick, encode};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode};
|
||||
pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image};
|
||||
|
||||
use super::*;
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name get -2, -3…) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
has_doc,
|
||||
export::export_for_screens
|
||||
),
|
||||
|
||||
@@ -278,3 +278,53 @@ fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_sublayers_are_left_out_too() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.ancestry(vectorcraft_doc::NodeId(red)).unwrap().len(), 3, "the square is on a sublayer");
|
||||
assert!(has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())));
|
||||
s.execute("layer.setProps", &json!({"id": sub, "template": true})).unwrap();
|
||||
assert!(!has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())), "a template sublayer is left out");
|
||||
s.execute("select.set", &json!({"ids": [red]})).unwrap();
|
||||
assert!(s.execute("document.exportSelection", &json!({"format": "png"})).is_err(), "nothing but template art is selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_vector_rows_drop_scale_suffixes_and_names_ignore_case() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[2].name.clear();
|
||||
// The dialog's second row starts as PNG @2x; switched to PDF it keeps that suffix text.
|
||||
let formats =
|
||||
json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "pdf", "scale": 2, "suffix": "@2x"}, {"format": "svg", "suffix": "-web"}]);
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": formats})).unwrap();
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"Icon.png",
|
||||
"Icon.pdf",
|
||||
"Icon-web.svg",
|
||||
"icon-2.png",
|
||||
"icon-2.pdf",
|
||||
"icon-2-web.svg",
|
||||
"Artboard-3.png",
|
||||
"Artboard-3.pdf",
|
||||
"Artboard-3-web.svg"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_raster_exports_are_refused() {
|
||||
let mut s = session(2000.0, 2000.0, 1);
|
||||
let e = s.execute("document.export", &json!({"format": "webp", "scale": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ fn recording_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
|
||||
#[test]
|
||||
fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0}});
|
||||
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}});
|
||||
let (addr, app) = recording_app();
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
assert!(!call_tool(&mut remote, "export", &args).is_error);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{FORMATS, OPEN_EXTS};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -736,8 +736,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
"open_file" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?),
|
||||
"save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?),
|
||||
"export" => {
|
||||
// One params object for every backend; the named arguments win over `options`.
|
||||
// One params object for every backend; the named arguments win over `options` (a named
|
||||
// artboard or range replaces every artboard choice in `options`).
|
||||
let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
params.remove(k);
|
||||
}
|
||||
}
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
|
||||
@@ -298,8 +298,7 @@ impl Renderer {
|
||||
if !self.draw_pattern_edit(&mut ctx, &frame) {
|
||||
// While editing an opacity mask its art is seen only through the mask (Illustrator).
|
||||
let mask_layer = doc.mask_edit.map(|m| m.layer);
|
||||
let skipped = |l: &Node| Some(l.id) == mask_layer || opts.skip_templates && matches!(l.kind, NodeKind::Layer { template: true, .. });
|
||||
for layer in doc.layers.iter().filter(|l| !skipped(l)) {
|
||||
for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
}
|
||||
}
|
||||
@@ -407,7 +406,8 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
|
||||
if !a.visible || f.opts.hidden.contains(&a.id) {
|
||||
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
|
||||
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
|
||||
return;
|
||||
}
|
||||
match self.bounds_of(a) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
|
||||
//! the host (a download on the web); the document keeps its path
|
||||
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
@@ -296,7 +296,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
}
|
||||
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
|
||||
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
|
||||
"app.export" => wrap(crate::io::export(app, s("format"), s("path").map(str::to_string), p).map(|p| json!({"path": p}))),
|
||||
"app.export" => match s("path") {
|
||||
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))),
|
||||
// No save dialog for an agent: the bytes come back, as in headless mode.
|
||||
None => wrap(app.run("document.export", p.clone())),
|
||||
},
|
||||
"app.quit" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
|
||||
@@ -249,4 +249,16 @@ mod tests {
|
||||
app.run("file.exportForScreens", Value::Null).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_export_without_path_returns_bytes() {
|
||||
let (mut app, written) = app();
|
||||
app.session.execute("file.new", &json!({"width": 30, "height": 20})).unwrap();
|
||||
let (req, _) = crate::control::ControlRequest::new("app.export", json!({"format": "png", "scale": 2}));
|
||||
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") };
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let png = vectorcraft_format::base64_decode(r["result"]["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[16..20], 60u32.to_be_bytes(), "30 pt at scale 2");
|
||||
assert!(written.borrow().is_empty(), "no file and no save dialog");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
|
||||
| `ui.resize` / `ui.focus` | | |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes through the host (a download on the web). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
|
||||
Reference in New Issue
Block a user