Merge pull request #112 from storytold/pr/m4-asset-export

M4.65–M4.66: Asset Export and Export Selection
This commit is contained in:
BFlat 2026-10-05 15:32:16 +07:00 committed by GitHub
commit 0f161926cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1599 additions and 94 deletions

57
crates/doc/src/assets.rs Normal file
View File

@ -0,0 +1,57 @@
//! Asset Export (Window → Asset Export, Object → Collect for Export): pieces of art collected to
//! export on their own, at the export settings Export for Screens shares.
//!
//! An asset names objects rather than copying them: it follows edits (a moved object moves the
//! crop, a recoloured one exports recoloured) and lets go of objects that are deleted
//! ([`Document::prune_assets`]; an asset left with none goes too). Asset ids come from the
//! document's id counter, so they never repeat within a document.
use serde::{Deserialize, Serialize};
use crate::{Document, NodeId};
/// One asset: what it is called (its files' names) and the objects it exports, cropped to their
/// visual bounds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExportAsset {
pub id: u64,
pub name: String,
pub nodes: Vec<NodeId>,
}
/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`…
const ASSET: &str = "Asset ";
impl Document {
/// Asset `id`.
pub fn asset(&self, id: u64) -> Option<&ExportAsset> {
self.assets.iter().find(|a| a.id == id)
}
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
pub fn next_asset_name(&self) -> String {
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
format!("{ASSET}{}", n.saturating_add(1))
}
/// Forget the objects of assets that are no longer in the document, and the assets left with
/// none (after every edit).
pub fn prune_assets(&mut self) {
if self.assets.iter().all(|a| a.nodes.iter().all(|id| self.node(*id).is_some())) {
return;
}
let mut assets = std::mem::take(&mut self.assets);
for a in &mut assets {
a.nodes.retain(|id| self.node(*id).is_some());
}
assets.retain(|a| !a.nodes.is_empty());
self.assets = assets;
}
/// `ids` that are in the document, back to front.
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
}
}

View File

@ -6,6 +6,7 @@
#![forbid(unsafe_code)]
pub mod appearance;
pub mod assets;
pub mod graph;
pub mod hit;
pub mod inks;
@ -49,6 +50,7 @@ pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
@ -526,6 +528,10 @@ pub struct Document {
/// as JSON: the print engine sits above this crate); `None` until they are set up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub print_setup: Option<serde_json::Value>,
/// Window → Asset Export: art collected for export ([`ExportAsset`]), in panel order. Their
/// export settings are Export for Screens' ([`Document::export_settings`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<ExportAsset>,
}
fn ppi72() -> f64 {
@ -592,6 +598,7 @@ impl Document {
slices: vec![],
slices_clip_to_artboard: true,
print_setup: None,
assets: vec![],
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@ -614,6 +621,7 @@ impl Document {
l.walk(&mut |n| max = max.max(n.id.0));
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
self.next_id = self.next_id.max(max + 1);
}

View File

@ -117,9 +117,7 @@ impl Selection {
}
/// Top-level ordering: selected ids sorted by paint order (bottom first).
pub fn in_paint_order(&self, doc: &Document) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = self.objects.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
doc.paint_order(self.objects.iter().copied())
}
}

View File

@ -0,0 +1,227 @@
//! Window → Asset Export and Object → Collect for Export: art collected as assets
//! ([`vectorcraft_doc::ExportAsset`]), exported each on its own, cropped to its art, at the export
//! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects
//! the selection this way and opens Export for Screens on its Assets tab (a UI command).
use serde_json::{Map, Value, json};
use vectorcraft_doc::{ExportAsset, NodeId};
use super::edit::{roots_of, selected_roots};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings};
use super::*;
/// The longest asset name kept (characters).
const MAX_NAME: usize = 255;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"assets.add",
"Collect for Export",
["Object", "Collect for Export"],
None,
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
has_doc,
add
),
cmd!(
"assets.remove",
"Remove Asset",
[],
None,
"{assets: [asset id…]} take assets out of the Asset Export panel (their art stays) → {removed: n}",
has_doc,
remove
),
cmd!("assets.rename", "Rename Asset", [], None, "{asset: id, name} rename an asset (its files' names)", has_doc, rename),
cmd!(
query "assets.list",
"Assets",
[],
None,
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order",
has_doc,
list
),
cmd!(
"assets.export",
"Export Assets",
[],
None,
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
cmd!(
"assets.settings.set",
"Asset Export Settings",
[],
None,
"{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
has_doc,
settings_set
),
]
}
/// `e` as an error of command `cmd` (for errors of the export underneath).
fn of_cmd(cmd: &str, e: EngineError) -> EngineError {
match e {
EngineError::BadParams { msg, .. } => bad(cmd, msg),
e => e,
}
}
/// `name` trimmed and at most [`MAX_NAME`] characters long (`None` when empty).
fn clean_name(name: &str) -> Option<String> {
let n: String = name.trim().chars().take(MAX_NAME).collect();
(!n.is_empty()).then_some(n)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.add";
let ids = match ids_param(p, "ids") {
Some(ids) => {
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
}
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(bad(C, "select the art to collect (or give its ids)"));
}
let name = str_param(p, "name").and_then(clean_name);
let sets: Vec<Vec<NodeId>> = if bool_or(p, "multiple", true) { ids.iter().map(|id| vec![*id]).collect() } else { vec![ids] };
if name.is_some() && sets.len() > 1 {
return Err(bad(C, "a name names one asset: give multiple: false, or one object"));
}
let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id);
let d = &s.doc()?.doc;
// Nothing new: no undo step.
if sets.iter().all(|set| existing(d, set).is_some()) {
return Ok(json!({ "assets": sets.iter().filter_map(|set| existing(d, set)).collect::<Vec<_>>(), "added": 0 }));
}
s.edit("Collect for Export", |d, _| {
let (mut out, mut added) = (vec![], 0);
for nodes in sets {
if let Some(id) = existing(d, &nodes) {
out.push(id);
continue;
}
let own = match nodes.as_slice() {
[one] => d.node(*one).and_then(|n| n.name.as_deref()).and_then(clean_name),
_ => None,
};
let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name());
let id = d.alloc_id().0;
d.assets.push(ExportAsset { id, name, nodes });
out.push(id);
added += 1;
}
Ok(json!({ "assets": out, "added": added }))
})
}
/// The asset ids of param `key`.
fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result<Vec<u64>> {
let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?;
ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, format!("{v} is not an asset id")))).collect()
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.remove";
let ids = asset_ids(p, "assets", C)?;
let n = s.doc()?.doc.assets.iter().filter(|a| ids.contains(&a.id)).count();
if n == 0 {
return Err(bad(C, "no such assets (see assets.list)"));
}
s.edit("Remove Asset", |d, _| {
d.assets.retain(|a| !ids.contains(&a.id));
Ok(json!({ "removed": n }))
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.rename";
let id = p.get("asset").and_then(Value::as_u64).ok_or_else(|| bad(C, "`asset` is the asset id (see assets.list)"))?;
let name = str_param(p, "name").and_then(clean_name).ok_or_else(|| bad(C, "give the asset a name"))?;
let at = s.doc()?.doc.assets.iter().position(|a| a.id == id).ok_or_else(|| bad(C, format!("no asset {id}")))?;
s.edit("Rename Asset", |d, _| {
if let Some(a) = d.assets.get_mut(at) {
a.name = name;
}
ok()
})
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let rows: Vec<Value> = d
.assets
.iter()
.map(|a| {
let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y));
json!({
"id": a.id,
"name": a.name,
"nodes": a.nodes.iter().map(|n| n.0).collect::<Vec<_>>(),
"bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]),
})
})
.collect();
Ok(json!({ "assets": rows }))
}
/// The shared export settings of the document (the [`SCREEN_SETTINGS_KEYS`] it has).
fn shared_settings(s: &Session) -> Result<Map<String, Value>> {
let saved = &s.doc()?.doc.export_settings;
Ok(SCREEN_SETTINGS_KEYS.iter().filter_map(|k| Some((k.to_string(), saved.get(*k).filter(|v| !v.is_null())?.clone()))).collect())
}
/// `base` with the settings of `p` over it: a given value replaces (null removes) the saved one;
/// formats drop a saved preset and a preset (`""`: none) saved formats.
fn overlay(base: &mut Map<String, Value>, p: &Map<String, Value>) {
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
if given("formats") && !p.contains_key("preset") {
base.remove("preset");
}
if given("preset") && !p.contains_key("formats") {
base.remove("formats");
}
for (k, v) in p {
match v {
Value::Null => _ = base.remove(k),
Value::String(id) if k == "preset" && id.is_empty() => _ = base.remove(k),
v => _ = base.insert(k.clone(), v.clone()),
}
}
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.export";
let mut q = shared_settings(s)?;
if let Some(o) = p.as_object() {
overlay(&mut q, o);
}
if !q.contains_key("assets") {
let all: Vec<u64> = s.doc()?.doc.assets.iter().map(|a| a.id).collect();
if all.is_empty() {
return Err(bad(C, "no assets yet: collect art for export first (assets.add)"));
}
q.insert("assets".into(), json!(all));
}
export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
}
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.settings.set";
let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?;
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) {
return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", "))));
}
let mut settings = s.doc()?.doc.export_settings.clone();
overlay(&mut settings, o);
check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?;
store_screen_settings(s, settings)?;
Ok(json!({ "settings": shared_settings(s)? }))
}

View File

@ -125,15 +125,22 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
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(cmd, "select something to export"))?;
let mut d = single_artboard(&st.doc, bounds, "Selection");
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
}
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
/// named `name`: their visual bounds (`None` when they have none).
pub(super) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?;
let mut d = single_artboard(doc, bounds, name);
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
Ok((d, bounds))
Some((d, bounds))
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on

View File

@ -58,6 +58,9 @@ pub use save::{
templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
/// file it replaces), for the apps' own writers too.
@ -110,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} 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}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} 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}]} (names include sub-folders: 2x/Icon@2x.png). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
@ -721,14 +724,20 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
}
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
/// (compared without case).
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
boards
.iter()
.map(|&b| {
let name = doc.artboards.get(b).map_or("", |a| a.name.as_str());
names
.into_iter()
.map(|(name, fallback)| {
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = format!("Artboard-{}", b + 1);
base = fallback;
}
let mut name = base.clone();
for i in 2.. {

View File

@ -1,7 +1,8 @@
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
//! whole document) in every format row at each row's size, into a folder and its sub-folders, or
//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings
//! the document remembers (`document.exportSettings`).
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
//! the presets the dialog offers; and the settings the document remembers
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
use std::collections::HashSet;
use std::sync::Arc;
@ -13,8 +14,8 @@ use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::without_artboards;
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file};
use super::export::{isolated, without_artboards};
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, writable, write_file};
const C: &str = "document.exportForScreens";
@ -219,6 +220,10 @@ impl Row {
}
}
/// The settings the Asset Export panel shares with the dialog: what is exported, not which art
/// or where to.
pub(crate) const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"];
/// What `document.exportForScreens` reads besides the formats.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@ -232,6 +237,45 @@ struct ScreenParams {
subfolders: bool,
preset: Option<String>,
prefix: Option<String>,
/// Asset ids (Asset Export) to write instead of artboards.
assets: Option<Vec<u64>>,
}
impl ScreenParams {
fn of(p: &Value) -> Result<Self> {
if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) }
}
}
/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the
/// format's `settings` and the top-level `antiAlias`.
fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result<Vec<Row>> {
let settings = p.get("settings").cloned().unwrap_or(Value::Null);
let rows: Vec<Value> = match (preset, p.get("formats")) {
(Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")),
(Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?,
(None, Some(Value::Array(rows))) => rows.clone(),
(None, None | Some(Value::Null)) => vec![json!({})],
(None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))),
};
let mut rows: Vec<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
if rows.is_empty() {
return Err(bad(C, "no formats to export"));
}
// A top-level anti-aliasing mode applies to every row that doesn't name its own.
if let Some(aa) = p.get("antiAlias") {
for r in &mut rows {
if let Some(m) = r.options.as_object_mut() {
m.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
}
Ok(rows)
}
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
}
/// The file names (and bytes) an export wrote, or the files it returns.
@ -272,31 +316,23 @@ impl Output {
}
}
/// File → Export for Screens: every chosen artboard in every format row, one file each (a PDF holds
/// its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, else the
/// bounds of all art). Artboards that share a name get `-2`, `-3`… instead of overwriting. The
/// params are remembered in the document (`document.exportSettings`).
/// File → Export for Screens: [`export`], with the params remembered in the document
/// (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let o: ScreenParams = if p.is_object() { ScreenParams::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ScreenParams::default() };
let settings = p.get("settings").cloned().unwrap_or(Value::Null);
let rows: Vec<Value> = match (o.preset.as_deref(), p.get("formats")) {
(Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")),
(Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?,
(None, Some(Value::Array(rows))) => rows.clone(),
(None, None | Some(Value::Null)) => vec![json!({})],
(None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))),
};
let mut rows: Vec<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
if rows.is_empty() {
return Err(bad(C, "no formats to export"));
}
// A top-level anti-aliasing mode applies to every row that doesn't name its own.
if let Some(aa) = p.get("antiAlias") {
for r in &mut rows {
if let Some(m) = r.options.as_object_mut() {
m.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
let r = export(s, p)?;
remember(s, p);
Ok(r)
}
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
/// `-2`, `-3`… instead of overwriting.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
if o.full_document && o.assets.is_some() {
return Err(bad(C, "give assets or fullDocument, not both"));
}
let subfolders = o.subfolders || o.preset.is_some();
// A prefix names files, not folders.
@ -347,6 +383,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
};
out.put(folder, o.zip, name, bytes)?;
}
} else if let Some(ids) = &o.assets {
let assets =
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
if assets.is_empty() {
return Err(bad(C, "no assets to export"));
}
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = encode(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
} else {
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
@ -365,7 +419,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
remember(s, p);
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
@ -387,17 +440,26 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Keep the params of a successful export in the document (without `zip`, a delivery detail), so
/// the dialog reopens on them. Not an undo step; the document has changed only if they differ.
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
/// `assets`, a choice of art), so the dialog reopens on them.
fn remember(s: &mut Session, p: &Value) {
let Some(o) = p.as_object() else { return };
let mut settings = o.clone();
settings.remove("zip");
let Ok(st) = s.doc_mut() else { return };
settings.remove("assets");
// Only after a successful export, which had a document.
let _ = store_settings(s, settings);
}
/// Make `settings` the document's export settings. Not an undo step (like the settings of any
/// export); the document has changed only if they differ.
pub(crate) fn store_settings(s: &mut Session, settings: Map<String, Value>) -> Result<()> {
let st = s.doc_mut()?;
if st.doc.export_settings != settings {
Arc::make_mut(&mut st.doc).export_settings = settings;
st.revision += 1;
}
Ok(())
}
/// `document.exportSettings`: the Export for Screens settings the document last exported with.

View File

@ -1,6 +1,7 @@
//! The command registry. Ids follow Illustrator's menu structure.
pub(crate) mod appearance;
mod assets;
mod attributes;
mod brushsym;
mod buildcmds;
@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(recovery::specs());
v.extend(fileio::dxfimport::specs());
v.extend(webexport::specs());
v.extend(assets::specs());
v
})
}

View File

@ -957,6 +957,10 @@ impl Session {
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
@ -1078,6 +1082,8 @@ mod tests;
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_assets;
#[cfg(test)]
mod tests_attributes;
#[cfg(test)]
mod tests_brushsym;

View File

@ -0,0 +1,267 @@
//! Asset Export: collecting art as assets (single and multiple), assets following their art
//! (moves, deletions), names, undo, the native round trip, the shared export settings and
//! exporting assets alone, cropped to their art.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Unstroked art: visual bounds are the geometry.
s.paint.stroke = Paint::None;
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
}
fn assets(s: &mut Session) -> Vec<Value> {
run(s, "assets.list", json!({}))["assets"].as_array().unwrap().clone()
}
fn names(s: &mut Session) -> Vec<String> {
assets(s).iter().map(|a| a["name"].as_str().unwrap().to_string()).collect()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// `(name, bytes)` of each file an export returned.
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
r["files"]
.as_array()
.unwrap()
.iter()
.map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()))
.collect()
}
#[test]
fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "select.set", json!({"ids": [a, b]}));
let r = run(&mut s, "assets.add", json!({}));
assert_eq!(r["added"], 2);
let list = assets(&mut s);
assert_eq!(list.len(), 2);
assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b])));
assert_eq!(r["assets"], json!([list[0]["id"], list[1]["id"]]));
let single = run(&mut s, "assets.add", json!({"multiple": false}));
assert_eq!(single["added"], 1);
let list = assets(&mut s);
assert_eq!(list.len(), 3);
assert_eq!(list[2]["nodes"], json!([a, b]), "one asset of both, back to front");
assert_eq!(list[2]["bounds"], json!([10.0, 10.0, 70.0, 20.0]), "the crop is the art's bounds");
// Collecting the same art again keeps its assets (and isn't an undo step).
let steps = undo_len(&s);
let again = run(&mut s, "assets.add", json!({"ids": [b, a]}));
assert_eq!((again["added"].clone(), again["assets"].clone()), (json!(0), json!([list[0]["id"], list[1]["id"]])));
assert_eq!(undo_len(&s), steps);
// Layers aren't art; nothing to collect is an error.
let layer = s.doc().unwrap().doc.layers[0].id.0;
assert!(s.execute("assets.add", &json!({"ids": [layer]})).is_err());
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("assets.add", &json!({})).is_err());
assert!(s.execute("assets.add", &json!({"ids": [a, b], "name": "Two"})).is_err(), "a name names one asset");
}
#[test]
fn deleting_the_object_prunes_it_and_undo_brings_it_back() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a]}));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false}));
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "edit.clear", json!({}));
let list = assets(&mut s);
assert_eq!(list.len(), 1, "the asset of the deleted object goes");
assert_eq!(list[0]["nodes"], json!([b]), "the other keeps the art that is left");
run(&mut s, "edit.undo", json!({}));
let list = assets(&mut s);
assert_eq!((list.len(), list[1]["nodes"].clone()), (2, json!([a, b])));
}
#[test]
fn moving_the_art_moves_the_crop() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
let before = files(&run(&mut s, "assets.export", json!({})));
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "object.move", json!({"dx": 40, "dy": 25}));
assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 35.0, 20.0, 20.0]));
let after = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(before, after, "the same art, cropped the same way");
assert_eq!(after[0].0, "Asset-1.png");
}
#[test]
fn an_asset_exports_its_art_alone() {
let mut s = session();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let red = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
// Blue art over it isn't part of the asset.
s.paint.fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
rect(&mut s, 0.0, 0.0, 25.0, 25.0);
run(&mut s, "assets.add", json!({"ids": [red]}));
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "2x"}]})));
assert_eq!(out[0].0, "Asset-1@2x.png");
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 40));
assert!(img.pixels().all(|p| p.0 == [255, 0, 0, 255]), "only the red square");
// Vector formats crop to it too.
let svg = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "svg"}]})));
let text = String::from_utf8(svg[0].1.clone()).unwrap();
assert_eq!(svg[0].0, "Asset-1.svg");
assert!(text.contains("viewBox=\"10 10 20 20\"") || text.contains("viewBox=\"0 0 20 20\""), "{text}");
assert!(!text.to_lowercase().contains("#0000ff"), "no blue: {text}");
}
#[test]
fn names_follow_the_object_or_count_up_and_can_be_changed() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 20.0, 20.0), rect(&mut s, 90.0, 10.0, 20.0, 20.0));
run(&mut s, "layer.setProps", json!({"id": b, "name": "Icon"}));
run(&mut s, "assets.add", json!({"ids": [a, b]}));
assert_eq!(names(&mut s), ["Asset 1", "Icon"]);
let first = assets(&mut s)[0]["id"].as_u64().unwrap();
run(&mut s, "assets.rename", json!({"asset": first, "name": " Asset 7 "}));
run(&mut s, "assets.add", json!({"ids": [c]}));
assert_eq!(names(&mut s), ["Asset 7", "Icon", "Asset 8"], "trimmed; numbering goes on from the highest");
run(&mut s, "assets.add", json!({"ids": [a, c], "multiple": false, "name": "Pair"}));
assert_eq!(names(&mut s)[3], "Pair");
assert!(s.execute("assets.rename", &json!({"asset": first, "name": " "})).is_err());
assert!(s.execute("assets.rename", &json!({"asset": 999_999, "name": "X"})).is_err());
// Assets that share a name (in any case) get -2, -3… instead of overwriting.
let ids: Vec<u64> = assets(&mut s).iter().map(|a| a["id"].as_u64().unwrap()).collect();
run(&mut s, "assets.rename", json!({"asset": ids[0], "name": "icon"}));
let out = files(&run(&mut s, "assets.export", json!({"assets": [ids[0], ids[1]]})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["icon.png", "Icon-2.png"]);
}
#[test]
fn add_remove_and_rename_are_undo_steps() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let steps = undo_len(&s);
let id = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap();
run(&mut s, "assets.rename", json!({"asset": id, "name": "Logo"}));
run(&mut s, "assets.remove", json!({"assets": [id]}));
assert_eq!(undo_len(&s), steps + 3);
assert!(assets(&mut s).is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Logo"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Asset 1"]);
run(&mut s, "edit.undo", json!({}));
assert!(assets(&mut s).is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&mut s), ["Asset 1"]);
assert!(s.execute("assets.remove", &json!({"assets": [12345]})).is_err());
assert!(s.execute("assets.remove", &json!({})).is_err());
}
#[test]
fn assets_round_trip_through_the_native_format() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false, "name": "Both"}));
let saved = s.doc().unwrap().doc.assets.clone();
let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string();
run(&mut s, "document.open", json!({"name": "assets.vectorcraft", "dataBase64": b64}));
assert_eq!(s.doc().unwrap().doc.assets, saved);
// A new asset never takes an id already used.
let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0);
let id = run(&mut s, "assets.add", json!({"ids": [c]}))["assets"][0].as_u64().unwrap();
assert!(saved.iter().all(|x| x.id != id));
// Documents without assets don't write the key, and files without it load.
let mut d = vectorcraft_doc::Document::new(10.0, 10.0);
let text = serde_json::to_string(&d).unwrap();
assert!(!text.contains("\"assets\""));
d = serde_json::from_str(&text).unwrap();
assert!(d.assets.is_empty());
}
#[test]
fn the_shared_settings_drive_the_export_and_arent_an_undo_step() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
let steps = undo_len(&s);
let r = run(&mut s, "assets.settings.set", json!({"formats": [{"format": "png", "scale": "1x"}, {"format": "svg"}], "prefix": "ic_"}));
assert_eq!(r["settings"]["prefix"], "ic_");
assert_eq!(undo_len(&s), steps);
assert!(s.doc().unwrap().is_dirty());
// Export for Screens reads them too (they are its remembered settings).
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][1]["format"], "svg");
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["ic_Asset-1.png", "ic_Asset-1.svg"]);
// A preset replaces the rows (and turns on sub-folders); rows replace a preset.
run(&mut s, "assets.settings.set", json!({"preset": "mobile"}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("formats").is_none() && settings["preset"] == "mobile");
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["1x/ic_Asset-1.png", "2x/ic_Asset-1@2x.png", "3x/ic_Asset-1@3x.png"]);
run(&mut s, "assets.settings.set", json!({"formats": [{"format": "jpg", "quality": 80}], "preset": ""}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("preset").is_none() && settings["formats"][0]["format"] == "jpg");
// Params given to one export win without changing the settings.
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "webp"}], "prefix": null})));
assert_eq!(out[0].0, "Asset-1.webp");
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg");
// Bad settings are refused and change nothing.
for bad in [json!({"formats": [{"format": "dxf"}]}), json!({"preset": "tiny"}), json!({"folder": "/tmp"}), json!([])] {
assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}");
}
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg");
}
#[test]
fn export_for_screens_writes_chosen_assets_and_keeps_its_rules() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
let ids = run(&mut s, "assets.add", json!({"ids": [a, b]}))["assets"].clone();
let r = run(&mut s, "document.exportForScreens", json!({"assets": [ids[1]], "formats": [{"format": "png", "scale": "60w"}]}));
let out = files(&r);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "Asset-2@60w.png");
assert_eq!(image::load_from_memory(&out[0].1).unwrap().to_rgba8().dimensions(), (60, 40), "the width of the asset, not the artboard");
// The choice of art isn't remembered; the formats are.
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("assets").is_none() && settings["formats"][0]["scale"] == "60w");
// A zip of every asset.
let zip = run(&mut s, "document.exportForScreens", json!({"assets": ids, "zip": true}));
assert_eq!(zip["files"], json!(["Asset-1.png", "Asset-2.png"]));
for bad in [json!({"assets": [ids[0]], "fullDocument": true}), json!({"assets": [424242]}), json!({"assets": []}), json!({"assets": "all"})] {
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
}
// No assets: nothing for assets.export to do.
run(&mut s, "assets.remove", json!({"assets": ids}));
assert!(s.execute("assets.export", &json!({})).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn assets_export_into_a_folder() {
let dir = std::env::temp_dir().join(format!("vc-assets-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().replace('\\', "/");
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a], "name": "Logo"}));
let r =
run(&mut s, "assets.export", json!({"folder": folder, "subfolders": true, "formats": [{"format": "png", "scale": "2x"}, {"format": "pdf"}]}));
assert_eq!(r["files"], json!([format!("{folder}/2x/Logo@2x.png"), format!("{folder}/PDF/Logo.pdf")]));
assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file());
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -35,7 +35,7 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
(
"file.exportForScreens",
"Export for Screens…",
"{folder?, zip?, artboards? | range? | fullDocument?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
"{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
),
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
];

View File

@ -1,14 +1,16 @@
//! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include
//! Bleed, thumbnails with checkboxes) and the Assets tab, the destination (folder picker, Open
//! Location after Export, Create Sub-folders; the web downloads instead), the format rows (scale
//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format), the presets, Format Settings (the gear)
//! and the prefix. It opens on the settings the document last exported with.
//! Bleed, thumbnails with checkboxes) and the Assets tab (the Asset Export panel's assets, with
//! checkboxes), the destination (folder picker, Open Location after Export, Create Sub-folders;
//! the web downloads instead), the format rows (scale as `2x`, `100w`, `100h` or `72ppi`, suffix,
//! format), the presets, Format Settings (the gear) and the prefix. It opens on the settings the
//! document last exported with; the format rows are the Asset Export panel's too ([`formats`]).
//!
//! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`,
//! `boards` ([bool] per artboard), `includeBleed`, `folder`, `openLocation`, `subfolders`,
//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of
//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|svg|pdf: {…}});
//! `__settings` names the format whose Format Settings show ("" when closed).
//! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`,
//! `folder`, `openLocation`, `subfolders`, `prefix`, `preset` (""|mobile|density: where the rows
//! came from), `formats` (rows of `document.exportForScreens`, `scale` as text), `settings`
//! ({png|png8|jpg|webp|gif|svg|pdf: {…}}); `__settings` names the format whose Format Settings
//! show ("" when closed).
use serde::Deserialize;
use serde_json::{Map, Value, json};
@ -24,7 +26,11 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
body,
confirm,
ok: Some("Export Artboard"),
ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }),
ok_label: Some(|app| match (io::is_web(app), app.ui.dialog.as_ref().is_some_and(|d| d.str("tab") == "assets")) {
(true, _) => "Download",
(false, true) => "Export Asset",
(false, false) => "Export Artboard",
}),
min_width: 660.0,
..DialogSpec::FORM
};
@ -54,10 +60,38 @@ const SETTINGS_LABELS: [&str; 7] = ["PNG", "PNG 8", "JPG", "WebP", "GIF", "SVG",
/// The sizes a row's scale list offers (any can be typed).
const SIZES: [&str; 12] = ["0.5x", "0.75x", "1x", "1.5x", "2x", "3x", "4x", "512w", "512h", "72ppi", "150ppi", "300ppi"];
/// The format rows of export settings `saved` (`document.exportSettings`), sizes shown as text:
/// the saved rows, else the saved preset's, else one PNG at 1x; and that preset.
pub(crate) fn saved_rows(saved: &Map<String, Value>) -> (Vec<Value>, Option<&str>) {
let get = |k: &str| saved.get(k).filter(|v| !v.is_null());
let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some());
let rows = match (get("formats").and_then(Value::as_array), preset) {
(Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(),
(_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(),
_ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})],
};
(rows, preset)
}
/// Open the dialog on its Assets tab with `checked` assets checked (every asset when `None`).
pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) {
open(app);
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("tab".into(), json!("assets"));
if let Some(c) = checked {
d.fields.insert("assets".into(), json!(c));
}
}
}
/// Open the dialog on the settings the active document last exported with (else one PNG row at
/// 1x, into the Desktop).
/// 1x, into the Desktop), every asset checked.
pub fn open(app: &mut VectorcraftApp) {
let (n, saved) = app.session.active().map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone())).unwrap_or_default();
let (n, saved, assets) = app
.session
.active()
.map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), st.doc.assets.iter().map(|a| a.id).collect::<Vec<_>>()))
.unwrap_or_default();
let get = |k: &str| saved.get(k).filter(|v| !v.is_null());
let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default);
// The artboards: Full Document, a range (or a list of them), else all.
@ -68,13 +102,7 @@ pub fn open(app: &mut VectorcraftApp) {
_ => "all",
};
let boards: Vec<bool> = (0..n).map(|i| chosen.as_ref().is_none_or(|c| c.contains(&i))).collect();
// The rows: the saved ones (or the saved preset's), sizes shown as text.
let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some());
let rows = match (get("formats").and_then(Value::as_array), preset) {
(Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(),
(_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(),
_ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})],
};
let (rows, preset) = saved_rows(&saved);
// Every format's settings: its defaults, then the saved ones.
let mut settings = Map::new();
for (id, _) in SETTINGS {
@ -90,6 +118,7 @@ pub fn open(app: &mut VectorcraftApp) {
"select": select,
"range": range_text(&boards),
"boards": boards,
"assets": assets,
"includeBleed": flag("includeBleed", false),
"folder": folder.unwrap_or_default(),
"openLocation": flag("openLocation", true),
@ -169,20 +198,32 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> {
return Err("add a format to export".into());
}
let mut p = Map::new();
let files = match d.str("select").as_str() {
"full" => {
p.insert("fullDocument".into(), json!(true));
rows.len()
// What each row writes a file of: the checked assets on the Assets tab, else the artboards.
let pieces = if d.str("tab") == "assets" {
let checked = checked_assets(app, d);
if checked.is_empty() {
return Err("check an asset to export".into());
}
"range" => {
let range = d.str("range");
// A bad range keeps the dialog open.
let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default();
p.insert("range".into(), json!(range));
chosen.len() * rows.len()
let count = checked.len();
p.insert("assets".into(), json!(checked));
count
} else {
match d.str("select").as_str() {
"full" => {
p.insert("fullDocument".into(), json!(true));
1
}
"range" => {
let range = d.str("range");
// A bad range keeps the dialog open.
let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default();
p.insert("range".into(), json!(range));
chosen.len()
}
_ => n,
}
_ => n * rows.len(),
};
let files = pieces * rows.len();
for k in ["includeBleed", "subfolders"] {
p.insert(k.into(), json!(d.bool(k)));
}
@ -223,8 +264,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.vertical(|ui| {
ui.set_width(270.0);
if tab == 1 {
ui.add_space(24.0);
widgets::dim_label(ui, "No assets to export yet: artwork collected for export shows here.");
assets(app, ui, d);
} else {
artboards(app, ui, d);
}
@ -240,6 +280,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
destination(app, ui, d);
ui.add_space(10.0);
formats(ui, d);
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Prefix:");
form::text(ui, d, "prefix", 140.0);
});
}
});
});
@ -325,6 +370,50 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// The checked assets that are still in the document, in panel order.
fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> {
let on: Vec<u64> = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default()
}
/// The Assets tab: each asset's thumbnail and checkbox, Select All and Clear.
fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let Some(st) = app.session.active() else { return };
if st.doc.assets.is_empty() {
ui.add_space(24.0);
widgets::dim_label(ui, "No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel.");
return;
}
let mut on = checked_assets(app, d);
let before = on.clone();
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
on = st.doc.assets.iter().map(|a| a.id).collect();
}
if ui.small_button("Clear").clicked() {
on.clear();
}
});
egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| {
for a in &st.doc.assets {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
crate::panels::asset_export::paint_thumb(ui, st, a, r);
let mut checked = on.contains(&a.id);
if ui.checkbox(&mut checked, a.name.as_str()).changed() {
on.retain(|id| *id != a.id);
if checked {
on.push(a.id);
}
}
});
}
});
if on != before {
d.fields.insert("assets".into(), json!(on));
}
}
/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the
/// web downloads instead.
fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
@ -347,9 +436,9 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
form::check(ui, d, "subfolders", "Create Sub-folders");
}
/// The preset, the Format Settings gear, the format rows (scale, suffix, format), Add Scale and the
/// prefix.
fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix,
/// format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset Export panel's rows.
pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let mut rows: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut edited = false;
ui.horizontal(|ui| {
@ -362,7 +451,7 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let preset = d.str("preset");
let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect();
let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1);
if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, 180.0) {
if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) {
let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id);
if let Some(preset_rows) = fileio::screen_preset_rows(id) {
rows = preset_rows;
@ -375,6 +464,10 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
});
let mut remove = None;
let one = rows.len() == 1;
// The columns at the dialog's widths, narrower in a narrower column (the panel): the three
// fields share what the remove buttons, the spacing and the fields' frames leave.
let k = ((ui.available_width() - 66.0) / 262.0).clamp(0.6, 1.0);
let (scale_w, suffix_w, format_w) = (96.0 * k, 70.0 * k, 96.0 * k);
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
for head in ["Scale", "Suffix", "Format", ""] {
widgets::dim_label(ui, head);
@ -384,7 +477,7 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster);
let scale = row["scale"].as_str().unwrap_or("1x").to_string();
ui.add_enabled_ui(raster, |ui| {
if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, 96.0) {
if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, scale_w) {
// The suffix follows the size while it is the automatic one.
let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix);
if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale))
@ -396,14 +489,14 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
edited = true;
}
});
if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), 70.0, 1) {
if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), suffix_w, 1) {
row["suffix"] = json!(s);
edited = true;
}
let (format, quality) = (row["format"].as_str().unwrap_or("png"), row["quality"].as_u64());
let current = FORMATS.iter().position(|(_, f, q)| *f == format && q.is_none_or(|q| Some(u64::from(q)) == quality));
let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string());
if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, 96.0).and_then(|i| FORMATS.get(i)) {
if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i)) {
row["format"] = json!(f);
if let Some(o) = row.as_object_mut() {
match q {
@ -443,11 +536,6 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
d.fields.insert("formats".into(), json!(rows));
d.fields.insert("preset".into(), json!(""));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Prefix:");
form::text(ui, d, "prefix", 140.0);
});
}
/// Format Settings: the options of format `id` that apply to every row of it, then Done.

View File

@ -73,6 +73,9 @@ pub use document_setup::open as open_document_setup;
pub use effect::open as open_effect_dialog;
pub use export_as::open as open_export_as;
pub use export_for_screens::open as open_export_for_screens;
pub(crate) use export_for_screens::{
KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_rows as screen_saved_rows,
};
pub use new_document::{open as open_new_document, preset_card};
pub use png_options::open as open_raster_options;
pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
@ -344,6 +347,8 @@ mod tests_raster_formats;
#[cfg(test)]
mod tests_dxf;
#[cfg(test)]
mod tests_screen_assets;
#[cfg(test)]
mod tests_screens;
#[cfg(test)]

View File

@ -0,0 +1,141 @@
//! Export for Screens' Assets tab and File → Export Selection…, drawn headlessly: collecting the
//! selection, the checked assets, the OK label, desktop folders and web downloads.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::tests_export::{frame, kind, set};
use super::*;
use crate::Services;
use crate::tests_labels::painted_text;
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with two unstroked squares (selected); `web` gives it a download service (recorded in
/// the log) instead of a file manager.
fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let log = Log::default();
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
} else {
services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
Ok(())
}));
}
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.session.paint.stroke = vectorcraft_color::Paint::None;
let mut rect = |x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap();
let ids = [rect(10.0), rect(100.0)];
app.run("select.set", json!({"ids": ids})).unwrap();
(app, log, ids)
}
fn field(app: &VectorcraftApp, k: &str) -> Value {
app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null)
}
fn assets(app: &mut VectorcraftApp) -> Vec<Value> {
app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone()
}
#[test]
fn export_selection_collects_the_selection_and_opens_the_assets_tab() {
let (mut app, log, _) = app(true);
assert!(crate::menus::enabled(&app, "file.exportSelection"));
let r = app.run("file.exportSelection", json!({})).unwrap();
let list = assets(&mut app);
assert_eq!(list.len(), 2, "one asset per object");
let ids: Vec<Value> = list.iter().map(|a| a["id"].clone()).collect();
assert_eq!(r["assets"], json!(ids));
assert_eq!(kind(&app), Some(export_for_screens::KIND));
assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("assets"), json!(ids)));
// Collecting the same art again opens on the same assets.
super::cancel(&mut app);
app.run("file.exportSelection", json!({})).unwrap();
assert_eq!(assets(&mut app).len(), 2);
assert_eq!(field(&app, "assets"), json!(ids));
let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx()));
assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Select All"), "{text}");
// The web downloads both as one zip.
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"], json!(["Asset-1.png", "Asset-2.png"]));
assert_eq!(log.borrow().len(), 1);
assert!(app.ui.dialog.is_none());
// Nothing selected: Export Selection is off; document.exportSelection still exports in one go.
app.run("select.set", json!({"ids": []})).unwrap();
assert!(!crate::menus::enabled(&app, "file.exportSelection"));
app.run("select.set", json!({"ids": [list[0]["nodes"][0]]})).unwrap();
assert!(app.session.execute("document.exportSelection", &json!({"format": "png"})).unwrap()["dataBase64"].is_string());
}
#[test]
fn export_selection_as_one_asset() {
let (mut app, _, ids) = app(true);
app.run("file.exportSelection", json!({"multiple": false})).unwrap();
let list = assets(&mut app);
assert_eq!(list.len(), 1);
assert_eq!(list[0]["nodes"], json!(ids));
assert_eq!(field(&app, "assets"), json!([list[0]["id"]]));
}
#[test]
fn the_assets_tab_exports_the_checked_assets_into_the_folder() {
let (mut app, log, _) = app(false);
app.run("assets.add", json!({})).unwrap();
let ids: Vec<u64> = assets(&mut app).iter().map(|a| a["id"].as_u64().unwrap()).collect();
app.run("file.exportForScreens", json!({})).unwrap();
assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("artboards"), json!(ids)), "every asset checked");
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
set(&mut app, "tab", json!("assets"));
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Asset");
frame(&mut app);
// Nothing checked keeps the dialog open.
set(&mut app, "assets", json!([]));
assert!(confirm(&mut app).is_err());
assert_eq!(kind(&app), Some(export_for_screens::KIND));
let dir = std::env::temp_dir().join(format!("vc-efs-assets-{}", std::process::id()));
let folder = dir.to_string_lossy().replace('\\', "/");
set(&mut app, "folder", json!(folder));
set(&mut app, "assets", json!([ids[1], 999]));
set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}]));
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"], json!([format!("{folder}/Asset-2@2x.png")]));
let png = image::load_from_memory(&std::fs::read(dir.join("Asset-2@2x.png")).unwrap()).unwrap();
assert_eq!((png.width(), png.height()), (60, 40), "the asset's art at 2x");
assert_eq!(log.borrow().len(), 1, "Open Location shows the file");
// The rows are remembered for the dialog and the Asset Export panel alike.
assert_eq!(app.run("document.exportSettings", json!({})).unwrap()["settings"]["formats"][0]["scale"], "2x");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_empty_assets_tab_says_how_to_collect_art() {
let (mut app, _, _) = app(false);
app.run("file.exportForScreens", json!({})).unwrap();
set(&mut app, "tab", json!("assets"));
let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx()));
assert!(text.contains("Collect for Export"), "{text}");
}
#[test]
fn the_menus_reach_asset_export() {
let (mut app, _, _) = app(false);
let entries = crate::menus::menu_entries(&app);
let find = |cmd: &str, label: &str| entries.iter().find(|e| e.command.as_deref() == Some(cmd) && e.label == label).map(|e| e.path.clone());
assert_eq!(find("file.exportSelection", "Export Selection…"), Some(vec!["File".to_string()]));
assert_eq!(find("assets.add", "As Single Asset"), Some(vec!["Object".to_string(), "Collect for Export".to_string()]));
assert_eq!(find("assets.add", "As Multiple Assets"), Some(vec!["Object".to_string(), "Collect for Export".to_string()]));
let window = entries.iter().find(|e| e.label == "Asset Export").unwrap();
assert_eq!(window.params, json!({"panel": crate::panels::asset_export::ID}));
app.run("window.panel", window.params.clone()).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some(crate::panels::asset_export::ID));
app.run("assets.add", json!({"multiple": false})).unwrap();
assert_eq!(assets(&mut app).len(), 1);
}

View File

@ -523,9 +523,14 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St
/// without one the web downloads them (the ZIP, or each file) and the other frontends get them
/// back as base64. With `openLocation`, the file manager shows the first file written.
pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
export_files(app, "document.exportForScreens", params)
}
/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`).
pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result<Value, String> {
let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false);
let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string();
let r = app.run("document.exportForScreens", params)?;
let r = app.run(id, params)?;
let count = r["files"].as_array().map_or(0, Vec::len);
// Files that came back as bytes: (name, base64).
let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() {

View File

@ -485,6 +485,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}",
),
(
"file.exportSelection",
"Export Selection…",
"",
"{multiple?: true} collect the selected objects as assets (assets.add: one per object; false: one of them all) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -871,6 +877,13 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app),
"file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()),
"file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p),
// File → Export Selection…: the selection becomes assets, shown checked on Export for
// Screens' Assets tab.
"file.exportSelection" => app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(true) })).map(|r| {
let ids: Vec<u64> = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
crate::dialogs::open_export_for_screens_assets(app, Some(&ids));
json!({ "assets": ids })
}),
_ => return None,
};
Some(r)
@ -1106,6 +1119,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
"ui.epsOptionsDialog" => app.session.active().is_some(),
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
"file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
_ => true,
}
}
@ -1169,7 +1183,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Save for Web (Legacy)…", "file.saveForWeb"),
],
),
c("Export Selection…", "document.exportSelection"),
c("Export Selection…", "file.exportSelection"),
Sep,
c("Package…", "ui.packageDialog"),
sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]),
@ -1458,6 +1472,13 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
],
),
sub("Graph", vec![c("Type…", "graph.setType"), c("Data…", "graph.setData"), todo("Design…"), todo("Column…"), todo("Marker…")]),
sub(
"Collect for Export",
vec![
cp("As Single Asset", "assets.add", json!({"multiple": false})),
cp("As Multiple Assets", "assets.add", json!({"multiple": true})),
],
),
],
),
(
@ -1690,7 +1711,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
panel("Align", "align"),
panel("Appearance", "appearance"),
panel("Artboards", "artboards"),
todo("Asset Export"),
panel("Asset Export", crate::panels::asset_export::ID),
panel("Attributes", "attributes"),
panel("Brushes", "brushes"),
panel("Color", "color"),

View File

@ -0,0 +1,366 @@
//! Asset Export panel (Window → Asset Export): the document's assets as thumbnails with their
//! names. Art dragged in off the canvas becomes assets, one per object (with Alt held, one asset
//! of them all), and so does the selection with the + button. Clicking selects assets (Shift or
//! Cmd adds), double-clicking one renames it and the bin removes the selected ones. Below are the
//! export settings, the format rows Export for Screens shows too (one set per document), and
//! Export, which writes the selected assets (else all of them) into a picked folder; the web
//! downloads them. The screen button opens Export for Screens on its Assets tab.
//!
//! Everything runs through the `assets.*` commands.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{ExportAsset, Node, NodeId};
use vectorcraft_engine::DocState;
use super::{pstate, set_pstate};
use crate::dialogs::{EXPORT_FOR_SCREENS, open_export_for_screens_assets, screen_formats, screen_saved_rows};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets::{self, PanelDrag, menu_item};
use crate::{VectorcraftApp, io};
pub const ID: &str = "assetExport";
/// A tile: the thumbnail over the name.
const TILE: egui::Vec2 = vec2(70.0, 84.0);
const THUMB: f32 = 58.0;
const SELECTED: &str = "asset-export-selected";
/// The assets selected in the panel (the panel's own state) that are still in the document.
fn selected(ctx: &egui::Context, st: &DocState) -> Vec<u64> {
let mut sel: Vec<u64> = pstate(ctx, SELECTED);
sel.retain(|id| st.doc.asset(*id).is_some());
sel
}
/// The addresses of an asset's objects, back to front: the identity of its look.
type Look = Vec<usize>;
/// A document (uid) at one revision.
type Revision = (u64, u64);
/// Asset `a`'s art fitted into `r` on white. Rendered once per look of its objects (an edited
/// object is a new allocation, so its address is a free change detector, as in the Layers panel).
pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) {
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
/// The addresses of each asset's objects in one document revision (no tree walks per frame).
static KEYS: RefCell<(Revision, HashMap<u64, Look>)> = RefCell::new(Default::default());
static CACHE: RefCell<HashMap<(Look, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
}
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
if !ui.is_rect_visible(r) {
return;
}
let ids = || st.doc.paint_order(a.nodes.iter().copied());
let nodes = KEYS.with(|k| {
let mut k = k.borrow_mut();
if k.0 != (st.uid, st.revision) {
*k = ((st.uid, st.revision), HashMap::new());
}
k.1.entry(a.id).or_insert_with(|| ids().iter().filter_map(|id| st.doc.node(*id)).map(|n| std::ptr::from_ref(n) as usize).collect()).clone()
});
let px = (r.width() * ui.ctx().pixels_per_point()).round().max(8.0) as u32;
let key = (nodes, px);
let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| {
let art: Vec<&Node> = ids().iter().filter_map(|id| st.doc.node(*id)).collect();
let img = RENDERER.with(|rr| match art.as_slice() {
[one] => rr.borrow_mut().render_node_thumbnail(&st.doc, one, px, None),
many => {
let group = Node::group(NodeId(0), many.iter().map(|n| Arc::new((*n).clone())).collect());
rr.borrow_mut().render_node_thumbnail(&st.doc, &group, px, None)
}
})?;
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(format!("asset-thumb-{}", a.id), color, egui::TextureOptions::LINEAR);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 512 {
c.clear();
}
c.insert(key, tex.clone());
});
Some(tex)
});
if let Some(t) = tex {
ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
}
}
/// What the tiles saw this frame (acted on after drawing).
#[derive(Default)]
struct Events {
/// An asset clicked: (id, Shift/Cmd held).
click: Option<(u64, bool)>,
/// An asset renamed.
rename: Option<(u64, String)>,
/// Art dropped on the panel: (ids, as one asset).
drop: Option<(Vec<NodeId>, bool)>,
}
/// The asset tiles: thumbnail, name (a text field while renaming), selection.
fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) {
let t = Tokens::get(ui.ctx());
let rename_id = egui::Id::new("asset-export-rename");
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for a in &st.doc.assets {
let (r, resp) = ui.allocate_exact_size(TILE, Sense::click());
let on = sel.contains(&a.id);
if on {
ui.painter().rect_filled(r, 2.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let th = Rect::from_min_size(egui::pos2(r.center().x - THUMB / 2.0, r.top() + 4.0), vec2(THUMB, THUMB));
paint_thumb(ui, st, a, th);
ui.painter().rect_stroke(th, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Outside);
let name_rect = Rect::from_min_max(egui::pos2(r.left() + 2.0, th.bottom() + 3.0), egui::pos2(r.right() - 2.0, r.bottom() - 1.0));
match &renaming {
Some((id, buf)) if *id == a.id => {
let mut buf = buf.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(egui::FontId::proportional(11.5)));
// Enter or a click elsewhere commits, Escape cancels.
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf.trim() != a.name && !buf.trim().is_empty() {
ev.rename = Some((a.id, buf.trim().to_string()));
}
} else {
te.request_focus();
ui.data_mut(|d| d.insert_temp(rename_id, (a.id, buf)));
}
}
_ => {
ui.painter().with_clip_rect(name_rect).text(
egui::pos2(name_rect.center().x, name_rect.center().y),
egui::Align2::CENTER_CENTER,
&a.name,
egui::FontId::proportional(11.5),
t.text,
);
}
}
let resp = resp.on_hover_text(format!("{}\nDouble-click to rename", a.name));
if resp.double_clicked() {
ui.data_mut(|d| d.insert_temp(rename_id, (a.id, a.name.clone())));
} else if resp.clicked() {
ev.click = Some((a.id, ui.input(|i| i.modifiers.shift || i.modifiers.command)));
}
}
});
}
/// Art dragged off the canvas over the list: outline it; dropped, it becomes assets.
fn drop_zone(ui: &Ui, zone: &egui::Response, ev: &mut Events) {
let Some(drag) = zone.dnd_hover_payload::<PanelDrag>() else { return };
let PanelDrag::Art(ids) = &*drag else { return };
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
if zone.dnd_release_payload::<PanelDrag>().is_some() {
ev.drop = Some((ids.clone(), ui.input(|i| i.modifiers.alt)));
}
}
/// Run `cmd`, reporting a failure in the status bar.
fn run(app: &mut VectorcraftApp, cmd: &str, params: Value) -> Option<Value> {
app.run(cmd, params).map_err(|e| app.status(e)).ok()
}
/// Collect `ids` (else the selection) as assets (one of them all with `single`), and select them
/// in the panel.
fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option<Vec<NodeId>>, single: bool) {
let mut p = json!({ "multiple": !single });
if let Some(ids) = ids {
p["ids"] = json!(ids.iter().map(|id| id.0).collect::<Vec<_>>());
}
if let Some(r) = run(app, "assets.add", p) {
let ids: Vec<u64> = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
set_pstate(ctx, SELECTED, ids);
}
}
/// Export assets `ids` (`files_each`: the files one asset makes) into a picked folder (without a
/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads
/// them, several as one zip.
fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
let mut p = json!({ "assets": ids });
if io::is_web(app) {
p["zip"] = json!(ids.len() * files_each > 1);
} else {
let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default();
let folder = match app.services.pick_folder.as_mut() {
Some(pick) => match pick() {
Some(f) => f,
None => return,
},
None => match saved
.get("folder")
.and_then(Value::as_str)
.filter(|f| !f.is_empty())
.map(str::to_string)
.or_else(vectorcraft_engine::cmd::fileio::export_folder)
{
Some(f) => f,
None => return app.status("choose a folder to export to".to_string()),
},
};
p["folder"] = json!(folder);
p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true));
}
if let Err(e) = io::export_files(app, "assets.export", p) {
app.status(e);
}
}
/// What the export settings rows asked for.
enum Settings {
/// Change the shared settings (`assets.settings.set`).
Set(Value),
/// Show a format's Format Settings (in Export for Screens).
Format(String),
}
/// The export settings: Export for Screens' format rows on the document's shared settings.
/// Returns the change asked for and how many files one asset makes.
fn settings(ui: &mut Ui, st: &DocState) -> (Option<Settings>, usize) {
let (rows, preset) = screen_saved_rows(&st.doc.export_settings);
let preset = preset.unwrap_or_default().to_string();
let n = rows.len();
let mut d = Dialog::new(EXPORT_FOR_SCREENS, json!({ "formats": &rows, "preset": preset, "__settings": "" }));
screen_formats(ui, &mut d);
let (shown, picked) = (d.str("__settings"), d.str("preset"));
let formats = d.fields.remove("formats").unwrap_or(Value::Null);
let change = if !shown.is_empty() {
Some(Settings::Format(shown))
} else if picked != preset && !picked.is_empty() {
Some(Settings::Set(json!({ "preset": picked, "subfolders": true })))
} else if picked != preset || formats.as_array() != Some(&rows) {
// Rows edited, or Custom picked: the rows as they are, without the preset.
Some(Settings::Set(json!({ "formats": formats, "preset": "" })))
} else {
None
};
(change, n)
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let ctx = ui.ctx().clone();
let Some(st) = app.session.active() else {
widgets::dim_label(ui, "No document");
return;
};
let sel = selected(&ctx, st);
let mut ev = Events::default();
widgets::list_box(ui, |ui| {
let out = egui::ScrollArea::vertical().id_salt("asset-export-scroll").max_height(270.0).auto_shrink([false, true]).show(ui, |ui| {
ui.set_min_height(110.0);
ui.set_width(ui.available_width());
if st.doc.assets.is_empty() {
super::empty_state(ui, "share-2", "No assets", "Drag art here, or select it and click +. Hold Alt for one asset of several objects.");
} else {
tiles(ui, st, &sel, &mut ev);
}
});
let zone = ui.interact(out.inner_rect, ui.id().with("asset-export-drop"), Sense::hover());
drop_zone(ui, &zone, &mut ev);
});
ui.add_space(6.0);
widgets::section_header(ui, "Export Settings");
let (change, files_each) = settings(ui, st);
let all: Vec<u64> = st.doc.assets.iter().map(|a| a.id).collect();
let has_art = !st.selection.is_empty();
let alt = ui.input(|i| i.modifiers.alt);
ui.add_space(6.0);
let mut export_clicked = false;
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_enabled_ui(!all.is_empty(), |ui| {
let label = if sel.is_empty() || sel.len() == all.len() { "Export".to_string() } else { format!("Export {} Selected", sel.len()) };
export_clicked = widgets::primary_button(ui, &label).on_hover_text("Export the selected assets (else all of them)").clicked();
});
});
});
let (mut screens, mut add, mut remove) = (false, false, false);
widgets::bottom_bar(ui, |ui| {
screens = widgets::icon_button(ui, "monitor", "Export for Screens…", false, 24.0).clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
remove = widgets::icon_button_enabled(ui, "trash-2", "Remove Selected Assets", false, !sel.is_empty(), 24.0).clicked();
add = widgets::icon_button_enabled(ui, "plus", "Add Selected Artwork (Alt: as one asset)", false, has_art, 24.0).clicked();
});
});
// Act on what the panel saw (the document is no longer borrowed).
if let Some((id, toggle)) = ev.click {
let mut s = sel.clone();
match (toggle, s.iter().position(|x| *x == id)) {
(true, Some(i)) => _ = s.remove(i),
(true, None) => s.push(id),
(false, _) => s = vec![id],
}
set_pstate(&ctx, SELECTED, s);
}
if let Some((id, name)) = ev.rename {
run(app, "assets.rename", json!({ "asset": id, "name": name }));
}
if let Some((ids, single)) = ev.drop {
collect(app, &ctx, Some(ids), single);
}
match change {
Some(Settings::Set(p)) => _ = run(app, "assets.settings.set", p),
Some(Settings::Format(f)) => {
open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all)));
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("__settings".into(), json!(f));
}
}
None => {}
}
if add {
collect(app, &ctx, None, alt);
}
if remove {
run(app, "assets.remove", json!({ "assets": sel }));
set_pstate(&ctx, SELECTED, Vec::<u64>::new());
}
if screens {
open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all)));
}
if export_clicked {
export(app, sel_or_all(&sel, &all), files_each);
}
}
/// The selected assets, else all of them.
fn sel_or_all(sel: &[u64], all: &[u64]) -> Vec<u64> {
if sel.is_empty() { all.to_vec() } else { sel.to_vec() }
}
/// The panel (≡) menu.
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let ctx = ui.ctx().clone();
let Some(st) = app.session.active() else { return };
let (has_art, sel) = (!st.selection.is_empty(), selected(&ctx, st));
let all: Vec<u64> = st.doc.assets.iter().map(|a| a.id).collect();
if menu_item(ui, "Add Selected Artwork as One Asset", has_art, false) {
collect(app, &ctx, None, true);
}
if menu_item(ui, "Add Selected Artwork as Multiple Assets", has_art, false) {
collect(app, &ctx, None, false);
}
ui.separator();
if menu_item(ui, "Select All Assets", !all.is_empty(), false) {
set_pstate(&ctx, SELECTED, all.clone());
}
if menu_item(ui, "Remove Selected Assets", !sel.is_empty(), false) {
run(app, "assets.remove", json!({ "assets": sel }));
set_pstate(&ctx, SELECTED, Vec::<u64>::new());
}
ui.separator();
if menu_item(ui, "Export for Screens…", true, false) {
open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all)));
}
}

View File

@ -7,6 +7,7 @@ pub mod actions;
pub mod align;
pub mod appearance;
pub mod artboards;
pub mod asset_export;
pub mod attributes;
pub mod brushes;
pub mod character;
@ -96,6 +97,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
"attributes" => attributes::show(app, ui),
"colorThemes" => color_themes::show(app, ui),
links::ID => links::show(app, ui),
asset_export::ID => asset_export::show(app, ui),
_ => {
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
}
@ -139,6 +141,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
"colorThemes" => color_themes::menu(app, ui),
"layers" => layers::menu(app, ui),
links::ID => links::menu(app, ui),
asset_export::ID => asset_export::menu(app, ui),
_ => {
ui.add_enabled(false, egui::Button::new("No options").frame(false));
}
@ -445,6 +448,8 @@ mod tests {
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_asset_export;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_freeform;

View File

@ -0,0 +1,206 @@
//! The Asset Export panel drawn headless: its empty state, art dropped in (one asset per object,
//! or one with Alt), selecting and renaming assets, the shared export settings rows and Export.
use std::cell::RefCell;
use std::rc::Rc;
use egui::{Pos2, Rect, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_engine::Session;
use super::asset_export::{ID, menu, show};
use crate::widgets::PanelDrag;
use crate::{Services, VectorcraftApp};
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with two unstroked squares; `web` gives it a download service (recorded in the log).
fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let log = Log::default();
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
}
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.session.paint.stroke = vectorcraft_color::Paint::None;
let mut rect = |x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap();
let ids = [rect(10.0), rect(100.0)];
(app, log, ids)
}
/// One frame of the panel, 236 pt wide as in the dock → its texts and where they are.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, alt: bool) -> Vec<(String, Rect)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut all = vec![egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })];
all.extend(events);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 700.0))), events: all, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
fn click(at: Pos2) -> Vec<egui::Event> {
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
vec![egui::Event::PointerMoved(at), button(true), button(false)]
}
fn release(at: Pos2) -> Vec<egui::Event> {
vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() },
]
}
/// Where text `label` was drawn.
fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
texts
.iter()
.find(|(t, _)| t == label)
.unwrap_or_else(|| panic!("no `{label}` in {:?}", texts.iter().map(|t| &t.0).collect::<Vec<_>>()))
.1
.center()
}
fn assets(app: &mut VectorcraftApp) -> Vec<Value> {
app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone()
}
fn has(texts: &[(String, Rect)], label: &str) -> bool {
texts.iter().any(|(t, _)| t == label)
}
#[test]
fn an_empty_panel_says_how_to_add_assets_and_shows_the_export_settings() {
let (mut app, _, _) = app(false);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
let texts = frame(&ctx, &mut app, vec![], false);
for label in ["No assets", "Export Settings", "Scale", "Suffix", "Format", "+ Add Scale", "Export"] {
assert!(has(&texts, label), "{label}");
}
// The menu draws.
let menu = crate::tests_labels::painted_text(&mut app, menu);
for item in ["Add Selected Artwork as One Asset", "Remove Selected Assets", "Export for Screens…"] {
assert!(menu.contains(item), "{item}: {menu}");
}
assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Asset Export"));
}
#[test]
fn art_dropped_on_the_panel_becomes_assets() {
let (mut app, _, [a, b]) = app(false);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(a), NodeId(b)]));
frame(&ctx, &mut app, release(pos2(100.0, 50.0)), false);
let list = assets(&mut app);
assert_eq!(list.len(), 2, "one asset per object");
// With Alt, one asset of them all.
egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(a), NodeId(b)]));
frame(&ctx, &mut app, release(pos2(100.0, 50.0)), true);
let list = assets(&mut app);
assert_eq!(list.len(), 3);
assert_eq!(list[2]["nodes"], json!([a, b]));
let texts = frame(&ctx, &mut app, vec![], false);
assert!(has(&texts, "Asset 1") && has(&texts, "Asset 3"));
// The new asset is the panel's selection.
assert!(has(&texts, "Export 1 Selected"));
}
#[test]
fn the_plus_button_collects_the_selection_and_the_bin_removes_selected_assets() {
let (mut app, _, [a, b]) = app(false);
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
let texts = frame(&ctx, &mut app, vec![], false);
// The + is the bottom bar's second icon from the right.
let bar_y = at(&texts, "Export").y + 34.0;
frame(&ctx, &mut app, click(pos2(236.0 - 40.0, bar_y)), false);
assert_eq!(assets(&mut app).len(), 2, "+ adds one asset per object");
let texts = frame(&ctx, &mut app, vec![], false);
// Click one: it alone is selected; the bin removes it (the art stays).
frame(&ctx, &mut app, click(at(&texts, "Asset 2")), false);
let texts = frame(&ctx, &mut app, vec![], false);
// The bin is the bottom bar's last icon.
frame(&ctx, &mut app, click(pos2(236.0 - 13.0, at(&texts, "Export 1 Selected").y + 34.0)), false);
let names: Vec<Value> = assets(&mut app).iter().map(|x| x["name"].clone()).collect();
assert_eq!(names, [json!("Asset 1")]);
assert!(app.session.active().unwrap().doc.node(NodeId(b)).is_some());
}
#[test]
fn renaming_an_asset_runs_assets_rename() {
let (mut app, _, [a, _]) = app(false);
let id = app.run("assets.add", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
// Double-click starts editing the name (as typed here); Enter commits it.
ctx.data_mut(|d| d.insert_temp(egui::Id::new("asset-export-rename"), (id, "Logo".to_string())));
frame(&ctx, &mut app, vec![], false);
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(&ctx, &mut app, vec![enter], false);
assert_eq!(assets(&mut app)[0]["name"], "Logo");
}
#[test]
fn the_rows_are_the_shared_export_settings() {
let (mut app, log, [a, b]) = app(true);
app.run("assets.add", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
let texts = frame(&ctx, &mut app, vec![], false);
// + Add Scale adds a 2x row to the document's export settings (Export for Screens' too).
frame(&ctx, &mut app, click(at(&texts, "+ Add Scale")), false);
let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone();
assert_eq!(settings["formats"].as_array().unwrap().len(), 2);
assert_eq!(settings["formats"][1]["scale"], "2x");
app.run("file.exportForScreens", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["formats"][1]["scale"], "2x");
crate::dialogs::cancel(&mut app);
// A preset set by an agent shows as its rows.
app.run("assets.settings.set", json!({"preset": "mobile"})).unwrap();
let texts = frame(&ctx, &mut app, vec![], false);
assert!(has(&texts, "Mobile 1x/2x/3x") && has(&texts, "@3x"));
// Export (nothing selected: every asset) downloads one zip on the web.
let texts = frame(&ctx, &mut app, vec![], false);
frame(&ctx, &mut app, click(at(&texts, "Export")), false);
let log = log.borrow();
assert_eq!(log.len(), 1);
assert_eq!(log[0].0, "Untitled-1.zip");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_writes_into_the_picked_folder() {
let dir = std::env::temp_dir().join(format!("vc-asset-panel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().replace('\\', "/");
let (mut app, _, [a, _]) = app(false);
let picked = folder.clone();
app.services.pick_folder = Some(Box::new(move || Some(picked.clone())));
app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
let texts = frame(&ctx, &mut app, vec![], false);
frame(&ctx, &mut app, click(at(&texts, "Export")), false);
assert!(dir.join("Badge.png").is_file(), "{:?}", std::fs::read_dir(&dir).map(|d| d.count()));
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -107,6 +107,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("attributes", "Attributes", "settings"),
("colorThemes", "Color Themes", "sun"),
(crate::panels::links::ID, "Links", "link"),
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
];
/// Groups of icon panels separated by dividers in the collapsed column.

View File

@ -32,6 +32,8 @@
| `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}` |
| 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`). Fields: `tab` (`artboards` \| `assets`), `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 |
| 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 |
| `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 |

View File

@ -1412,3 +1412,25 @@ defaults); presets and remembered settings are preferences.
{"name":"run_command","arguments":{"command":"document.exportForWeb","params":{"path":"/tmp/web/page.html","format":"png8","output":"html"}}}
{"name":"run_command","arguments":{"command":"webExport.presets.save","params":{"name":"Banner","format":"jpg","quality":70}}}
```
## Asset Export
Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, File ›
Export Selection…).
`assets.add {ids?, multiple?}` collects the selected objects (or `ids`): one asset per object, or with
`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; art already collected keeps
its asset. Assets name their objects, so they follow edits: moving the art moves the crop, and deleting it
removes it from its asset (an asset left with nothing goes). `assets.list` gives each asset's id, name, objects and
crop (`bounds`); `assets.rename {asset, name}` and `assets.remove {assets}` are undo steps.
`assets.export {assets?, folder?, zip?}` writes each asset's art alone, cropped to it and named after it, in every
format row of the document's export settings, the ones Export for Screens remembers (`document.exportSettings`); any
`formats`, `preset`, `settings`, `prefix` or `subfolders` given win for that export. `assets.settings.set` changes
those shared settings (not an undo step). `document.exportForScreens {assets: [id…]}` does the same with its own params
(the dialog's Assets tab).
```json
{"name":"run_command","arguments":{"command":"assets.add","params":{"multiple":true}}}
{"name":"run_command","arguments":{"command":"assets.settings.set","params":{"formats":[{"format":"png","scale":"1x"},{"format":"png","scale":"2x"},{"format":"svg"}]}}}
{"name":"run_command","arguments":{"command":"assets.export","params":{"zip":true}}}
```