Merge pull request #89 from storytold/pr/m4-save-pipeline
M4: Save As format chooser, Save keeps its format, Save a Copy, Revert, templates, File menu details (M4.14–M4.17)
This commit is contained in:
commit
db9d937e1a
|
|
@ -13,7 +13,7 @@ mod native_menu;
|
|||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
|
||||
|
||||
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
|
|
@ -97,14 +97,49 @@ fn save_prefs(app: &VectorcraftApp) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A native file dialog showing `pick`'s file types, folder and suggested name.
|
||||
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
||||
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.set_directory(folder),
|
||||
None => d,
|
||||
};
|
||||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.args(["-R", path]);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
|
||||
// and needs backslashes.
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(folder);
|
||||
c
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
fileio::open_filters()
|
||||
.fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_open_multi: Some(Box::new(|| {
|
||||
fileio::place_filters()
|
||||
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
|
||||
|
|
@ -114,13 +149,12 @@ fn services() -> Services {
|
|||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
// Saving a document offers every format Save writes (.vectorcraft, .ai, .svg, .svgz).
|
||||
pick_save: Some(Box::new(|name: &str| {
|
||||
fileio::save_filters(name)
|
||||
.into_iter()
|
||||
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, exts)| d.add_filter(label, exts))
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
pick_save: Some(Box::new(|pick: &FilePick| {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
|
|
@ -130,6 +164,7 @@ fn services() -> Services {
|
|||
open_url: Some(Box::new(|url: &str| {
|
||||
let _ = webbrowser::open(url);
|
||||
})),
|
||||
reveal: Some(Box::new(reveal)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
|
||||
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second.
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt
|
||||
//! when saved views or recent files come and go.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
|
@ -22,6 +23,8 @@ pub struct NativeMenu {
|
|||
last_refresh: f64,
|
||||
/// Shortcut-override / workspace-list generation the menu was built for.
|
||||
generation: u64,
|
||||
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
|
||||
slots: usize,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
|
|
@ -53,7 +56,7 @@ impl NativeMenu {
|
|||
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
|
||||
.map(|(_, _, _, c)| c.clone())
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation }
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) }
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
|
|
@ -69,8 +72,11 @@ impl NativeMenu {
|
|||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited or workspaces added: rebuild so accelerators and lists are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation {
|
||||
// Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so
|
||||
// accelerators and lists are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|
||||
|| menus::listed_slots(app) != self.slots
|
||||
{
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
}
|
||||
|
|
@ -137,6 +143,8 @@ fn build(
|
|||
build(app, &sub, children, items, counter);
|
||||
let _ = parent.append(&sub);
|
||||
}
|
||||
// Unused saved-view and recent-file slots are left out, as in the in-window menus.
|
||||
Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {}
|
||||
Item::Cmd(label, cmd, params) => {
|
||||
*counter += 1;
|
||||
let id = format!("dc{counter}");
|
||||
|
|
|
|||
|
|
@ -493,6 +493,10 @@ pub struct Document {
|
|||
/// ([`Document::raster_effects_ppi`]).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub raster_effects: RasterEffectsSettings,
|
||||
/// The view (zoom, centre, rotation) the document was saved with; it reopens there. Written at
|
||||
/// save time only, so changing the view never marks the document modified.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_view: Option<SavedView>,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
|
|
@ -552,6 +556,7 @@ impl Document {
|
|||
paste_remembers_layers: false,
|
||||
metadata: DocMetadata::default(),
|
||||
raster_effects: RasterEffectsSettings::default(),
|
||||
last_view: None,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
|
|
|
|||
|
|
@ -51,9 +51,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(
|
||||
"object.convertDocumentColorMode",
|
||||
"Convert Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
[],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept; Gray and Lab colours stay) through the colour settings → {changed}",
|
||||
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
has_doc,
|
||||
convert_mode
|
||||
),
|
||||
|
|
@ -235,54 +235,75 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
|
||||
}
|
||||
|
||||
fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.convertDocumentColorMode";
|
||||
/// File → Document Color Mode (`file.documentColorMode` and its alias
|
||||
/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every
|
||||
/// colour through the colour settings.
|
||||
pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let c = cms::active();
|
||||
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
// definitions) are device independent and fit either mode.
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let intent = intent_param(p, C)?;
|
||||
// Choosing the mode the document already has (the checked menu item) is not an edit.
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return Ok(json!({ "changed": 0 }));
|
||||
}
|
||||
let convert = bool_or(p, "convert", true);
|
||||
let mut changed = 0usize;
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
d.color_mode = mode;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = conv(&st.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed = set_color_mode(d, mode, convert, intent);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and
|
||||
/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed.
|
||||
/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
/// definitions) are device independent and fit either mode. Document Color Mode and
|
||||
/// `document.open {colorMode}` both convert this way.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>) -> usize {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return 0;
|
||||
}
|
||||
let c = cms::active();
|
||||
let intent = intent.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let mut changed = 0usize;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = conv(&st.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
fn values(c: &Color) -> Vec<f32> {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ use std::collections::BTreeSet;
|
|||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
|
||||
use super::colorcmds::{Recolor, Scope, to_cmyk};
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
|
||||
|
|
@ -149,9 +148,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
|
||||
has_doc,
|
||||
color_mode
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
cmd!(
|
||||
"file.info",
|
||||
|
|
@ -444,43 +443,3 @@ fn close_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
Ok(json!({ "closed": n }))
|
||||
}
|
||||
|
||||
fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let convert = bool_or(p, "convert", true);
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
set_color_mode(d, mode, convert);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return;
|
||||
}
|
||||
let f = move |c: vectorcraft_color::Color| match mode {
|
||||
ColorMode::Cmyk => to_cmyk(c),
|
||||
ColorMode::Rgb => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
vectorcraft_color::Color::rgb(r, g, b)
|
||||
}
|
||||
};
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
|
||||
for sw in d.swatches_iter_mut() {
|
||||
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
|
||||
*color = f(*color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
}
|
||||
let bytes = match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
// A native file flagged so opening it starts a new untitled document.
|
||||
"template" => {
|
||||
let mut d = doc.clone();
|
||||
d.template = true;
|
||||
vectorcraft_format::save_file(&d)
|
||||
}
|
||||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
||||
"pdf" => {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
|||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if format.id == "vectorcraft" {
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use vectorcraft_geom::Affine;
|
|||
|
||||
use super::super::*;
|
||||
use super::pdfimport::LoadOptions;
|
||||
use super::{Format, format, format_for_name, read_file};
|
||||
use super::{Format, SAVE_FORMATS, format, format_for_name, read_file};
|
||||
use crate::EngineError;
|
||||
|
||||
/// A file read into a document, with its format and non-fatal import notes.
|
||||
|
|
@ -18,6 +18,9 @@ pub struct Loaded {
|
|||
pub warnings: Vec<String>,
|
||||
/// The document is the native one the file carries (SVG or PDF saved with Preserve Editing).
|
||||
pub restored: bool,
|
||||
/// An older native file (the former `.drawcraft` name or format version): saving it again
|
||||
/// rewrites it in today's format.
|
||||
pub converted: bool,
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
|
|
@ -46,7 +49,8 @@ pub fn file_name(name: &str) -> String {
|
|||
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
let by_name = format_for_name(name).filter(|f| f.read);
|
||||
if vectorcraft_format::sniff(bytes) {
|
||||
return format("vectorcraft");
|
||||
// A template keeps its meaning from the extension, like .ait.
|
||||
return by_name.filter(|f| f.id == "template").or_else(|| format("vectorcraft"));
|
||||
}
|
||||
if vectorcraft_svg::is_svgz(bytes) {
|
||||
return format("svgz");
|
||||
|
|
@ -112,8 +116,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
}
|
||||
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
"vectorcraft" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false),
|
||||
"vectorcraft" | "template" => {
|
||||
let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?;
|
||||
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
|
||||
(d, vec![], false)
|
||||
}
|
||||
"svg" | "svgz" => {
|
||||
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
|
||||
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
|
||||
|
|
@ -128,21 +137,21 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::docmenu::set_color_mode(&mut doc, mode, true);
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None);
|
||||
}
|
||||
// Imports are named after the file; a native document keeps its own title (the tab shows the
|
||||
// file name once it has a path).
|
||||
if format.id != "vectorcraft" || doc.title.is_empty() {
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
Ok(Loaded { doc, format, warnings, restored })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted })
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. `path`
|
||||
/// is kept for Save only for a non-template native file or a `.ai` file whose native document came
|
||||
/// back (Save writes both again); the document's linked files are looked for from it
|
||||
/// ([`crate::cmd::links::resolve`]).
|
||||
/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. The
|
||||
/// document keeps `path` (and its format, for Save) when Save can write that format back: not a
|
||||
/// template, a partly read PDF, or a `.ai` file whose native document didn't come back. The
|
||||
/// document's linked files are looked for from `path` ([`crate::cmd::links::resolve`]).
|
||||
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
|
||||
open_bytes_with(s, name, bytes, path, &Value::Null)
|
||||
}
|
||||
|
|
@ -150,16 +159,40 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
|
|||
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
|
||||
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
|
||||
let opts = LoadOptions::from_params("document.open", p)?;
|
||||
let Loaded { mut doc, format, warnings, restored } = load_with(name, bytes, &opts)?;
|
||||
open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false)
|
||||
}
|
||||
|
||||
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
|
||||
/// from there) as a new untitled document.
|
||||
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
|
||||
open_loaded(s, load(name, bytes)?, path.map(str::to_string), &LoadOptions::default(), true)
|
||||
}
|
||||
|
||||
/// Make a loaded file the new active document; `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted } = loaded;
|
||||
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
|
||||
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
|
||||
let template = doc.template || format.id == "ait";
|
||||
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
|
||||
// document.
|
||||
let template = as_template || doc.template || matches!(format.id, "ait" | "template");
|
||||
if template {
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
}
|
||||
let keep_path = (format.id == "vectorcraft" || (format.id == "ai" && restored)) && !template;
|
||||
let index = s.add_document(doc, path.filter(|_| keep_path));
|
||||
// Save writes the file back only in a format it writes, only a whole PDF (see
|
||||
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
|
||||
// writes .ai that way).
|
||||
let partial = format.id == "pdf" && opts.is_partial();
|
||||
let lossy_ai = format.id == "ai" && !restored;
|
||||
let path = path.filter(|_| !template && !partial && !lossy_ai && SAVE_FORMATS.contains(&format.id));
|
||||
let saves_back = path.is_some();
|
||||
let converted = saves_back && converted && s.prefs.append_converted;
|
||||
let index = s.add_document(doc, path);
|
||||
let st = s.doc_mut()?;
|
||||
if saves_back {
|
||||
st.format = format.id;
|
||||
}
|
||||
st.converted = converted;
|
||||
let title = s.documents()[index].title();
|
||||
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
|
||||
}
|
||||
|
|
@ -189,6 +222,11 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p)
|
||||
}
|
||||
|
||||
pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, "file.newFromTemplate")?;
|
||||
open_template(s, src.name, &src.bytes, src.path)
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels).
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
|
||||
//! - `save`: `document.save`, `file.saveAsTemplate`.
|
||||
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
|
||||
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
|
||||
//!
|
||||
|
|
@ -29,9 +30,9 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc
|
|||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
use load::err;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub use save::{SAVE_FORMATS, save_encoding, save_filters, save_format, stamp_save_dates};
|
||||
pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates, templates_folder};
|
||||
pub use svg::options_map as svg_options;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -48,21 +49,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always,
|
||||
load::open
|
||||
),
|
||||
cmd!(
|
||||
"document.save",
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|ai|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}, …document.exportPdf options (.ai)} (default path: the document's) → {path, linked?, warnings?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A .ai save is a PDF-compatible file: every artboard as a PDF page with the native document embedded (preserveEditing is always on), so document.open restores it exactly. A never-saved document without path → {dataBase64} (stays modified)",
|
||||
has_doc,
|
||||
save::save
|
||||
),
|
||||
cmd!(
|
||||
query "document.serialize",
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
|
|
@ -71,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
|
|
@ -84,15 +76,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAsTemplate",
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
|
||||
has_doc,
|
||||
save::save_template
|
||||
),
|
||||
cmd!(
|
||||
"document.exportForScreens",
|
||||
"Export for Screens",
|
||||
|
|
@ -122,6 +105,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
pdfimport::pdf_info
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(save::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One option a format's encoder reads from the export params.
|
||||
|
|
@ -287,18 +273,28 @@ pub const FORMATS: &[Format] = &[
|
|||
},
|
||||
reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true),
|
||||
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
|
||||
Format {
|
||||
id: "template",
|
||||
label: "VectorCraft Template",
|
||||
extensions: &["vctemplate"],
|
||||
mime: "application/json",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
},
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
pub const OPEN_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate"];
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "txt"];
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "txt"];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
|
|
@ -331,6 +327,11 @@ pub fn extension(name: &str) -> String {
|
|||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A file name or path without its folder and extension (`/a/Poster.svg` → `Poster`).
|
||||
pub fn file_stem(name: &str) -> String {
|
||||
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The format a file name's extension names.
|
||||
pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
||||
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
|
||||
|
|
|
|||
|
|
@ -241,3 +241,9 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(json!({ "settings": v, "presets": presets(s), "changed": diff, "warnings": warnings }))
|
||||
}
|
||||
|
||||
/// Is `key` a top-level PDF setting (a [`PdfSettings`] field as `document.exportPdf` names it)?
|
||||
pub fn is_setting(key: &str) -> bool {
|
||||
static DEFAULTS: std::sync::LazyLock<Value> = std::sync::LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default());
|
||||
DEFAULTS.get(key).is_some()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ impl LoadOptions {
|
|||
Ok(Self { pages, crop, password, color_mode })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
/// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name).
|
||||
pub fn is_partial(&self) -> bool {
|
||||
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some()
|
||||
}
|
||||
|
||||
/// The PDF import options: `pages` resolved against the file's page count.
|
||||
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
|
||||
let pages = match &self.pages {
|
||||
|
|
|
|||
|
|
@ -1,68 +1,87 @@
|
|||
//! Saves: `document.save` (native, SVG or PDF-compatible .ai) and `file.saveAsTemplate`.
|
||||
//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`,
|
||||
//! `file.newFromTemplate`, `file.revert` and `file.formatOptions`.
|
||||
//!
|
||||
//! Every frontend saves the same way: [`save_plan`] resolves where and how (path, format, options,
|
||||
//! a suggested name and folder when there is no path), then [`save_with`] encodes, writes through
|
||||
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
|
||||
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::*;
|
||||
use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return};
|
||||
use crate::DocState;
|
||||
use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file};
|
||||
use crate::{DocState, Prefs};
|
||||
|
||||
/// The formats Save writes, in the order save dialogs offer them; other formats are exports.
|
||||
pub const SAVE_FORMATS: [&str; 4] = ["vectorcraft", "ai", "svg", "svgz"];
|
||||
/// The formats Save As offers, in menu order (append-only). The other writable formats are exports:
|
||||
/// they never become the document's own file.
|
||||
pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"];
|
||||
|
||||
/// The format Save writes: `format` (an id or extension), else the format the path's extension
|
||||
/// names, else native (see [`SAVE_FORMATS`]). A `.ai` file is a PDF carrying the native document.
|
||||
pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
let f = match (format, path.and_then(format_for_name)) {
|
||||
(Some(id), _) => super::format(id).filter(|f| SAVE_FORMATS.contains(&f.id)).map_or_else(|| writable_format(format, None), Ok)?,
|
||||
(None, Some(f)) => f,
|
||||
(None, None) => super::format("vectorcraft").ok_or("no native format")?,
|
||||
};
|
||||
if SAVE_FORMATS.contains(&f.id) {
|
||||
Ok(f)
|
||||
} else {
|
||||
Err(format!("Save writes .{}, .ai, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label))
|
||||
}
|
||||
/// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose
|
||||
/// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it
|
||||
/// leading (the panel's default type), else none (an export picks its own). Only the extension a
|
||||
/// save writes (never the former native name).
|
||||
pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] };
|
||||
let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect();
|
||||
v.sort_by_key(|f| f.id != first.id);
|
||||
v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect()
|
||||
}
|
||||
|
||||
/// Save dialog filters for a file named `name`: when Save writes its format, every format Save
|
||||
/// writes (that one first), else none.
|
||||
pub fn save_filters(name: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
let Some(first) = format_for_name(name).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] };
|
||||
let others = SAVE_FORMATS.iter().filter_map(|id| super::format(id)).filter(|f| f.id != first.id);
|
||||
std::iter::once(first).chain(others).map(|f| (f.label, f.extensions)).collect()
|
||||
/// How a save treats the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SaveMode {
|
||||
/// File → Save: the document's own path and format.
|
||||
Save,
|
||||
/// File → Save As: a new path or format, which the document takes on.
|
||||
SaveAs,
|
||||
/// File → Save a Copy: the document keeps its path, title and modified state.
|
||||
Copy,
|
||||
/// File → Save as Template: a template copy; the document keeps its path and state too.
|
||||
Template,
|
||||
}
|
||||
|
||||
/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and
|
||||
/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the
|
||||
/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say
|
||||
/// otherwise. A native file (and the document a `.ai` file carries) records its links' paths
|
||||
/// relative to `path`.
|
||||
pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> {
|
||||
const C: &str = "document.save";
|
||||
let relative = path.filter(|_| matches!(f.id, "vectorcraft" | "ai")).and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path));
|
||||
let doc = relative.as_ref().unwrap_or(&st.doc);
|
||||
match f.id {
|
||||
"vectorcraft" => return Ok((encode_all(doc, f.id, p)?, Value::Null)),
|
||||
// A PDF of every artboard with the PDF options in `p`, always carrying the native document.
|
||||
"ai" => {
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
}
|
||||
let (bytes, warnings) = super::pdf::encode(C, &doc.without_edit_modes(), &q)?;
|
||||
return Ok((Encoded { warnings, ..Encoded::one(bytes) }, Value::Null));
|
||||
impl SaveMode {
|
||||
/// The engine command of this mode.
|
||||
pub fn command(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "document.save",
|
||||
Self::SaveAs => "file.saveAs",
|
||||
Self::Copy => "file.saveCopy",
|
||||
Self::Template => "file.saveAsTemplate",
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let given = super::svg_options(p).map_err(|e| bad(C, e))?;
|
||||
let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) };
|
||||
let mut svg = opts.as_object().cloned().unwrap_or_default();
|
||||
svg.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
let enc = encode_all(&st.doc, f.id, &json!({ "svg": svg }))?;
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(C, "Save writes one artboard: name one, or export several with document.export"));
|
||||
|
||||
/// The mode an engine command id runs.
|
||||
pub fn of(command: &str) -> Option<Self> {
|
||||
[Self::Save, Self::SaveAs, Self::Copy, Self::Template].into_iter().find(|m| m.command() == command)
|
||||
}
|
||||
}
|
||||
|
||||
/// A save resolved against the active document, before anything is encoded or written.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SavePlan {
|
||||
pub mode: SaveMode,
|
||||
/// Where to write. `None` when no path is known (never saved, converted from an older version,
|
||||
/// or a format other than the document's own): the caller asks for one or takes the bytes.
|
||||
pub path: Option<String>,
|
||||
pub format: &'static Format,
|
||||
/// The format's options (only those its encoder reads).
|
||||
pub options: Map<String, Value>,
|
||||
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.vctemplate`.
|
||||
pub name: String,
|
||||
/// Suggested folder: the document's own, or the Templates folder for a template.
|
||||
pub folder: Option<String>,
|
||||
}
|
||||
|
||||
impl SavePlan {
|
||||
/// Does writing this make the file the document's own (Save and Save As, except to a
|
||||
/// template)?
|
||||
pub fn retargets(&self) -> bool {
|
||||
matches!(self.mode, SaveMode::Save | SaveMode::SaveAs) && self.format.id != "template"
|
||||
}
|
||||
Ok((enc, opts))
|
||||
}
|
||||
|
||||
/// File Info's dates for a save to a file: the modified date (and the created date, when it has
|
||||
|
|
@ -74,38 +93,309 @@ pub fn stamp_save_dates(st: &mut DocState) {
|
|||
d.metadata.modified = Some(now);
|
||||
}
|
||||
|
||||
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.save";
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
|
||||
let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?;
|
||||
if path.is_some() {
|
||||
stamp_save_dates(s.doc_mut()?);
|
||||
/// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates`
|
||||
/// in the user's home (none where there is no home folder, as on the web).
|
||||
pub fn templates_folder(prefs: &Prefs) -> Option<String> {
|
||||
if !prefs.templates_folder.is_empty() {
|
||||
return Some(prefs.templates_folder.clone());
|
||||
}
|
||||
let expanded = super::pdf::expand_preset(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
let st = s.doc()?;
|
||||
let (enc, opts) = save_encoding(st, f, p, path.as_deref())?;
|
||||
// The encoder's notes (a .ai file's PDF options not applied yet…).
|
||||
let notes = if enc.warnings.is_empty() { json!({}) } else { json!({ "warnings": enc.warnings }) };
|
||||
let Some(path) = path else {
|
||||
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
|
||||
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, notes);
|
||||
};
|
||||
let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?;
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.save_options = opts;
|
||||
st.mark_saved();
|
||||
let mut r = super::merge(json!({ "path": path }), notes);
|
||||
if let Some(linked) = out.get("linked") {
|
||||
r["linked"] = linked.clone();
|
||||
}
|
||||
Ok(r)
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty())?;
|
||||
Some(std::path::Path::new(&home).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
|
||||
pub(super) fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d = (*s.doc()?.doc).clone();
|
||||
d.template = true;
|
||||
write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({}))
|
||||
/// The folder of a path (`None` for a bare file name).
|
||||
fn parent_folder(path: &str) -> Option<String> {
|
||||
std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// A save format by id or extension, or why it isn't one.
|
||||
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
|
||||
match format(f) {
|
||||
Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f),
|
||||
Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)),
|
||||
None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The format Save writes for `format` (an id or extension), else the format `path`'s extension
|
||||
/// names, else native (see [`SAVE_FORMATS`]); other formats are exports.
|
||||
pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft"))
|
||||
}
|
||||
|
||||
/// [`save_format_of`] for command `cmd`.
|
||||
fn checked(cmd: &str, f: &str) -> Result<&'static Format> {
|
||||
save_format_of(f).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
/// Resolve a save of the active document from `{path?, format?, options?}` (see the commands'
|
||||
/// params docs).
|
||||
pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
let cmd = mode.command();
|
||||
let st = s.doc()?;
|
||||
let path = str_param(p, "path").map(str::to_string);
|
||||
let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) {
|
||||
(SaveMode::Template, ..) => checked(cmd, "template")?,
|
||||
(_, Some(f), _) => checked(cmd, f)?,
|
||||
(_, None, Some(f)) => checked(cmd, f.id)?,
|
||||
(_, None, None) => checked(cmd, st.format)?,
|
||||
};
|
||||
let mut given: Map<String, Value> = match p.get("options") {
|
||||
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
None | Some(Value::Null) => Map::new(),
|
||||
Some(_) => return Err(bad(cmd, "options must be an object (see file.formatOptions)")),
|
||||
};
|
||||
if is_svg(format) {
|
||||
// SVG options also come flat or as `svg: {…}` beside the path; kept flat.
|
||||
let mut svg = super::svg_options(&Value::Object(given)).map_err(|e| bad(cmd, e))?;
|
||||
svg.extend(super::svg_options(p).map_err(|e| bad(cmd, e))?);
|
||||
given = svg;
|
||||
} else if is_pdf(format) {
|
||||
// So do PDF options (as the Save PDF dialog and document.exportPdf name them).
|
||||
if let Some(o) = p.as_object() {
|
||||
given.extend(
|
||||
o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
// None given: the ones the document was last saved with in this format.
|
||||
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
|
||||
// Save writes the document's own file; the other modes only where they are told to.
|
||||
let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title));
|
||||
let ext = format.extensions[0];
|
||||
let name = match mode {
|
||||
SaveMode::Copy => format!("{stem} copy.{ext}"),
|
||||
SaveMode::Template => format!("{stem} template.{ext}"),
|
||||
SaveMode::Save | SaveMode::SaveAs => format!("{stem}.{ext}"),
|
||||
};
|
||||
let folder = match mode {
|
||||
SaveMode::Template => templates_folder(&s.prefs),
|
||||
_ => st.path.as_deref().and_then(parent_folder),
|
||||
};
|
||||
Ok(SavePlan { mode, path, format, options, name, folder })
|
||||
}
|
||||
|
||||
/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General
|
||||
/// settings (`createLayers`…) and presets.
|
||||
fn reads_option(f: &Format, key: &str) -> bool {
|
||||
match is_pdf(f) {
|
||||
true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key),
|
||||
false => f.options.iter().any(|o| o.name == key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats that carry the whole native document: they lose nothing, and record links relative to
|
||||
/// where they are written. A `.ai` file is a PDF carrying it.
|
||||
fn is_native(f: &Format) -> bool {
|
||||
matches!(f.id, "vectorcraft" | "template" | "ai")
|
||||
}
|
||||
|
||||
fn is_pdf(f: &Format) -> bool {
|
||||
matches!(f.id, "pdf" | "ai")
|
||||
}
|
||||
|
||||
fn is_svg(f: &Format) -> bool {
|
||||
matches!(f.id, "svg" | "svgz")
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at.
|
||||
fn doc_to_save<'a>(st: &'a DocState, f: &Format) -> Cow<'a, Document> {
|
||||
if is_native(f) && st.doc.last_view != st.view {
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
Cow::Owned(d)
|
||||
} else {
|
||||
Cow::Borrowed(&st.doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a format loses against a native file (reported whenever a save writes it).
|
||||
fn fidelity_warning(f: &Format) -> Option<String> {
|
||||
(!is_native(f)).then(|| {
|
||||
format!(
|
||||
"{} keeps the artwork but not everything a VectorCraft document holds (editable effects, symbols, swatches, styles): save as VectorCraft to keep it all editable",
|
||||
f.label
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode `plan` and write it with `write` (the file, then the images an SVG links to, beside it).
|
||||
/// Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: [{name,
|
||||
/// dataBase64}]}` and the document is unchanged; with one → `{path, format, bytes, warnings,
|
||||
/// linked?: [path…]}`, and Save or Save As (except to a template) make the file the document's
|
||||
/// own: path, title, format, options, saved state.
|
||||
pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let cmd = plan.mode.command();
|
||||
let retargets = plan.retargets();
|
||||
if plan.path.is_some() && retargets {
|
||||
stamp_save_dates(s.doc_mut()?);
|
||||
}
|
||||
let own = doc_to_save(s.doc()?, plan.format);
|
||||
// A native file records its links' paths relative to where it is written.
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
|
||||
let doc: &Document = relative.as_ref().unwrap_or(&own);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
params.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
}
|
||||
let params = Value::Object(params);
|
||||
let params = super::pdf::expand_preset(s, cmd, ¶ms)?;
|
||||
let mut enc = if plan.format.id == "ai" {
|
||||
// A PDF of every artboard with the PDF options, always carrying the native document.
|
||||
let mut q = params.into_owned();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
}
|
||||
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &q)?;
|
||||
Encoded { warnings, ..Encoded::one(bytes) }
|
||||
} else {
|
||||
encode_all(doc, plan.format.id, ¶ms)?
|
||||
};
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
// What the format loses first, then the encoder's own notes (PDF options not applied yet…).
|
||||
let mut warnings = std::mem::take(&mut enc.warnings);
|
||||
warnings.splice(0..0, fidelity_warning(plan.format));
|
||||
let extra = json!({ "format": plan.format.id, "warnings": warnings });
|
||||
let Some(path) = plan.path else {
|
||||
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
|
||||
out["name"] = json!(plan.name);
|
||||
if let Some(folder) = plan.folder {
|
||||
out["folder"] = json!(folder);
|
||||
}
|
||||
return Ok(out);
|
||||
};
|
||||
let files = enc.named(doc, &path);
|
||||
for (p, bytes) in &files {
|
||||
write(p, bytes)?;
|
||||
}
|
||||
let bytes = files.first().map_or(0, |f| f.1.len());
|
||||
let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra);
|
||||
if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
|
||||
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
|
||||
}
|
||||
if retargets {
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.format = plan.format.id;
|
||||
st.save_options = plan.options;
|
||||
st.converted = false;
|
||||
st.mark_saved();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A save command: plan, then write with the file system.
|
||||
fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> {
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
save_with(s, plan, write_file)
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
match (&st.path, st.is_dirty()) {
|
||||
(None, _) => Err("the document has never been saved".into()),
|
||||
(_, false) => Err("no changes since the last save".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Revert: read and decode the saved file first (on any failure the document is left as it
|
||||
/// is), then replace the document in its tab.
|
||||
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?;
|
||||
doc.template = false;
|
||||
s.replace_document(index, doc);
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// `file.formatOptions`: a writable format's options with the values Save would use.
|
||||
fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.formatOptions";
|
||||
let st = s.active();
|
||||
let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft");
|
||||
let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
|
||||
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
|
||||
let mut v = f.to_json();
|
||||
if let Some(options) = v["options"].as_object_mut() {
|
||||
for (name, o) in options.iter_mut() {
|
||||
o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(&o["default"]).clone();
|
||||
}
|
||||
}
|
||||
v["saveFormats"] =
|
||||
SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect();
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"document.save",
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAs",
|
||||
"Save As…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+S"),
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::SaveAs, p)
|
||||
),
|
||||
cmd!(
|
||||
"file.saveCopy",
|
||||
"Save a Copy…",
|
||||
["File"],
|
||||
Some("Cmd+Alt+S"),
|
||||
"{path?, format?, options?} (as file.saveAs) write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Copy, p)
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAsTemplate",
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
cmd!(
|
||||
"file.newFromTemplate",
|
||||
"New from Template…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+N"),
|
||||
"{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}",
|
||||
always,
|
||||
super::load::new_from_template
|
||||
),
|
||||
cmd!(
|
||||
"file.revert",
|
||||
"Revert",
|
||||
["File"],
|
||||
Some("F12"),
|
||||
"{} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true",
|
||||
can_revert,
|
||||
revert
|
||||
),
|
||||
cmd!(
|
||||
query "file.formatOptions",
|
||||
"Format Options",
|
||||
[],
|
||||
None,
|
||||
"{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
|
||||
always,
|
||||
format_options
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
|||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]);
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ fn color_mode_opens_the_file_in_the_mode_asked_for() {
|
|||
s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb);
|
||||
let f = fills(&s);
|
||||
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}");
|
||||
// Converted as Document Color Mode does: no CMYK left, greys stay grey.
|
||||
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. } | Color::Gray { .. }))), "{f:?}");
|
||||
assert!(matches!(f[0].color(), Some(Color::Rgb { .. })) && matches!(f[1].color(), Some(Color::Gray { .. })), "{f:?}");
|
||||
// Any format opens in the mode asked for.
|
||||
let mut svg = Session::new();
|
||||
svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
|
||||
|
|
|
|||
|
|
@ -106,8 +106,9 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
|
|||
s.execute("document.save", &json!({})).unwrap();
|
||||
let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers);
|
||||
// PDF stays an export.
|
||||
assert!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).is_err(), "PDF is written with document.exportPdf");
|
||||
// Save As also writes a plain PDF (the document then saves back as PDF); PNG stays an export.
|
||||
assert_eq!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).unwrap()["format"], "pdf");
|
||||
assert!(s.execute("document.save", &json!({"path": tmp("x.png")})).is_err(), "PNG is written with document.export");
|
||||
// A plain PDF named .ai opens as artwork, without a path to save over.
|
||||
let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap());
|
||||
let plain_path = tmp("plain.ai");
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ fn save_writes_svg_and_remembers_its_options() {
|
|||
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()));
|
||||
assert_eq!(s.doc().unwrap().save_options, Value::Null);
|
||||
assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -394,3 +394,12 @@ pub use flatten::FlattenOptions;
|
|||
|
||||
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
|
||||
pub use flatten::FlattenerPreset;
|
||||
|
||||
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
|
||||
/// They run as before but the command palette leaves them out (it lists the command once).
|
||||
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
|
||||
|
||||
/// Is `id` an [`ALIASES`] entry?
|
||||
pub fn is_alias(id: &str) -> bool {
|
||||
ALIASES.iter().any(|(a, _)| *a == id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,12 +171,13 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
let index = if pdf { 0 } else { page - 1 };
|
||||
let n = doc.artboards.len();
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| {
|
||||
let what = if format.id == "vectorcraft" { "artboard" } else { "page" };
|
||||
let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" };
|
||||
bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)"))
|
||||
})?;
|
||||
// A PDF import has one layer per page; a native document's art is whatever lies on the
|
||||
// artboard.
|
||||
let pages: Vec<&Arc<Node>> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(index).into_iter().collect() };
|
||||
let pages: Vec<&Arc<Node>> =
|
||||
if matches!(format.id, "vectorcraft" | "template") { layers.iter().collect() } else { layers.get(index).into_iter().collect() };
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board));
|
||||
(nodes, crop.then_some(board))
|
||||
|
|
|
|||
|
|
@ -315,6 +315,9 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
|||
p!("japaneseCropMarks", "General", "Options", "Use Japanese Crop Marks", bool),
|
||||
// Appearance panel menu
|
||||
p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool),
|
||||
// File Handling (continued)
|
||||
p!("templatesFolder", "File Handling", "Files", "Templates Folder", text),
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
|
|
|
|||
|
|
@ -112,16 +112,27 @@ pub struct DocState {
|
|||
pub mask_view: Option<NodeId>,
|
||||
/// View → Show Transparency Grid, per document (view state: not saved, not undoable).
|
||||
pub transparency_grid: bool,
|
||||
/// The SVG options this document was last saved with as SVG (Save reuses them; JSON as in
|
||||
/// `document.save {svg}`, null when none).
|
||||
pub save_options: Value,
|
||||
/// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened
|
||||
/// from or last saved as.
|
||||
pub format: &'static str,
|
||||
/// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF;
|
||||
/// empty for native files): Save reuses them and `file.formatOptions` reads them back.
|
||||
pub save_options: serde_json::Map<String, Value>,
|
||||
/// Opened from an older native file (former name or format version): the title says
|
||||
/// "[Converted]" and Save asks for a new name instead of overwriting it.
|
||||
pub converted: bool,
|
||||
/// The view saved into native files (`Document::last_view`); the UI keeps it current before a
|
||||
/// save and restores it when the document opens.
|
||||
pub view: Option<vectorcraft_doc::SavedView>,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
impl DocState {
|
||||
pub fn new(doc: Document, path: Option<String>) -> Self {
|
||||
pub fn new(mut doc: Document, path: Option<String>) -> Self {
|
||||
let active_layer = doc.default_layer();
|
||||
// The saved view lives here while the document is open (saves write it back).
|
||||
let view = doc.last_view.take();
|
||||
let doc = Arc::new(doc);
|
||||
Self {
|
||||
saved_doc: doc.clone(),
|
||||
|
|
@ -138,7 +149,10 @@ impl DocState {
|
|||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
mask_view: None,
|
||||
transparency_grid: false,
|
||||
save_options: Value::Null,
|
||||
format: "vectorcraft",
|
||||
save_options: Default::default(),
|
||||
converted: false,
|
||||
view,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
|
|
@ -150,11 +164,13 @@ impl DocState {
|
|||
self.saved_doc = self.doc.clone();
|
||||
}
|
||||
pub fn title(&self) -> String {
|
||||
self.path
|
||||
let name = self
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| self.doc.title.clone())
|
||||
.unwrap_or_else(|| self.doc.title.clone());
|
||||
if self.converted { format!("{} [Converted]", cmd::fileio::file_stem(&name)) } else { name }
|
||||
}
|
||||
/// Where new art is inserted: the isolation container, else the active layer.
|
||||
/// The current layer, if the remembered id still names a layer (ids are reused after undo).
|
||||
|
|
@ -368,6 +384,11 @@ pub struct Prefs {
|
|||
/// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub pdf_presets: Vec<vectorcraft_pdf::PdfPreset>,
|
||||
// File Handling (continued)
|
||||
/// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`).
|
||||
pub templates_folder: String,
|
||||
/// Open older native files as "<name> [Converted]" so Save asks for a new name.
|
||||
pub append_converted: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -494,6 +515,8 @@ impl Default for Prefs {
|
|||
new_doc_presets: vec![],
|
||||
recent_new_docs: vec![],
|
||||
pdf_presets: vec![],
|
||||
templates_folder: String::new(),
|
||||
append_converted: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -639,10 +662,9 @@ impl Session {
|
|||
self.untitled_counter += 1;
|
||||
format!("Untitled-{}", self.untitled_counter)
|
||||
}
|
||||
/// Add a document and make it active.
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
// Text layout bounds are a cache (not saved): compute them now, or selection boxes and
|
||||
// hit testing would use the rough estimate until each text object is edited.
|
||||
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
|
||||
/// selection boxes and hit testing would use the rough estimate until each text is edited.
|
||||
fn refresh_text_bounds(doc: &mut Document) {
|
||||
let mut texts = vec![];
|
||||
doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
|
|
@ -656,6 +678,10 @@ impl Session {
|
|||
cmd::typecmd::refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Add a document and make it active.
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
self.reset_tool_for_doc_switch();
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
|
|
@ -664,6 +690,30 @@ impl Session {
|
|||
self.active = Some(i);
|
||||
i
|
||||
}
|
||||
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
|
||||
/// selection, saved state. The tab keeps its place, path, format and view.
|
||||
pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool {
|
||||
if index >= self.docs.len() {
|
||||
return false;
|
||||
}
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
if self.active == Some(index) {
|
||||
// Pending tool work (typing, a drag) belongs to the content being thrown away.
|
||||
self.reset_tool_for_doc_switch();
|
||||
}
|
||||
let old = &self.docs[index];
|
||||
let mut st = DocState::new(doc, old.path.clone());
|
||||
st.history.limit = old.history.limit;
|
||||
// Same open document (caches keyed by uid stay valid); a new revision redraws it.
|
||||
st.uid = old.uid;
|
||||
st.revision = old.revision + 1;
|
||||
st.format = old.format;
|
||||
st.save_options = old.save_options.clone();
|
||||
st.converted = old.converted;
|
||||
st.view = old.view.clone();
|
||||
self.docs[index] = st;
|
||||
true
|
||||
}
|
||||
pub fn close_document(&mut self, index: usize) -> bool {
|
||||
if index >= self.docs.len() {
|
||||
return false;
|
||||
|
|
@ -1014,6 +1064,8 @@ mod tests_recolor;
|
|||
#[cfg(test)]
|
||||
mod tests_registration;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
|
|
@ -672,7 +672,9 @@ fn document_color_mode_converts_colours() {
|
|||
fill(&mut s, a, "#ff0000");
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk);
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
// Converted through the colour settings: a press red, mostly magenta and yellow.
|
||||
let Some(Color::Cmyk { c, m, y, k }) = node(&s, a).appearance.fill_paint().color() else { panic!("not CMYK") };
|
||||
assert!(m > 0.8 && y > 0.8 && c < 0.05 && k < 0.05, "{c} {m} {y} {k}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb);
|
||||
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,339 @@
|
|||
//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert,
|
||||
//! templates, converted legacy files, the saved view and the single Document Color Mode command.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::SavedView;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::fileio::{SaveMode, save_plan};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) {
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
}
|
||||
|
||||
/// A fresh folder for one test.
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-save-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn path(d: &std::path::Path, name: &str) -> String {
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.node_count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_path_writes_svg_and_save_writes_it_back() {
|
||||
let d = dir("svg");
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
let svg = path(&d, "art.svg");
|
||||
let r = s.execute("document.save", &json!({"path": svg})).unwrap();
|
||||
assert_eq!(r["format"], "svg");
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("SVG keeps the artwork"), "{r}");
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().starts_with("<?xml"));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some(svg.as_str()), "svg", false));
|
||||
// Save writes the same file in the same format.
|
||||
rect(&mut s);
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().starts_with("<?xml"));
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
// A reopened SVG keeps its path and saves as SVG again.
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": svg})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().format), (Some(svg.as_str()), "svg"));
|
||||
rect(&mut s);
|
||||
assert_eq!(s.execute("document.save", &json!({})).unwrap()["format"], "svg");
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().starts_with("<?xml"));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_takes_on_path_format_and_options() {
|
||||
let d = dir("saveas");
|
||||
let mut s = session();
|
||||
let pdf = path(&d, "doc.pdf");
|
||||
s.execute("file.saveAs", &json!({"path": pdf, "options": {"range": "1", "bogus": 1}})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.format, st.title()), ("pdf", "doc.pdf".to_string()));
|
||||
assert_eq!(Value::Object(st.save_options.clone()), json!({"range": "1"}), "only the format's own options are kept");
|
||||
assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF"));
|
||||
// The remembered options are what Save and the options dialog use.
|
||||
let o = s.execute("file.formatOptions", &json!({})).unwrap();
|
||||
assert_eq!((o["id"].as_str(), o["options"]["range"]["value"].as_str()), (Some("pdf"), Some("1")));
|
||||
assert_eq!(o["options"]["artboards"]["value"], Value::Null);
|
||||
let svg = s.execute("file.formatOptions", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(svg["options"]["outlineText"]["value"], false, "another format shows its defaults");
|
||||
let ids: Vec<&str> = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]);
|
||||
// SVGZ is gzip-compressed SVG and reopens as SVGZ.
|
||||
let svgz = path(&d, "doc.svgz");
|
||||
s.execute("file.saveAs", &json!({"path": svgz})).unwrap();
|
||||
assert_eq!(&std::fs::read(&svgz).unwrap()[..2], [0x1f, 0x8b]);
|
||||
s.execute("document.open", &json!({"path": svgz})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().format, "svgz");
|
||||
// Export formats aren't save formats.
|
||||
let e = s.execute("file.saveAs", &json!({"path": path(&d, "x.png")})).unwrap_err().to_string();
|
||||
assert!(e.contains("document.export"), "{e}");
|
||||
assert!(s.execute("document.save", &json!({"format": "webp"})).is_err());
|
||||
assert!(s.execute("file.saveAs", &json!({"path": svgz, "options": 3})).is_err());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_format_without_a_path_hands_the_bytes_back() {
|
||||
let d = dir("otherfmt");
|
||||
let mut s = session();
|
||||
let native = path(&d, "a.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
let r = s.execute("document.save", &json!({"format": "svg"})).unwrap();
|
||||
assert!(String::from_utf8(b64(&r)).unwrap().starts_with("<?xml"));
|
||||
assert_eq!(r["name"], "a.svg");
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()), "the native file isn't overwritten with SVG");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_a_copy_keeps_the_path_title_and_modified_state() {
|
||||
let d = dir("copy");
|
||||
let mut s = session();
|
||||
let native = path(&d, "poster.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
rect(&mut s);
|
||||
let r = s.execute("file.saveCopy", &json!({})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["folder"].as_str()), (Some("poster copy.vectorcraft"), Some(d.to_string_lossy().as_ref())));
|
||||
let copy = path(&d, "poster copy.svg");
|
||||
s.execute("file.saveCopy", &json!({"path": copy})).unwrap();
|
||||
assert!(std::fs::read_to_string(©).unwrap().starts_with("<?xml"));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.format, st.title()), (Some(native.as_str()), "vectorcraft", "poster.vectorcraft".to_string()));
|
||||
assert!(st.is_dirty(), "a copy leaves the document modified");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_reloads_the_saved_file_in_the_same_tab() {
|
||||
let d = dir("revert");
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
let native = path(&d, "a.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
assert!(s.execute("file.revert", &json!({})).is_err(), "nothing to revert");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.set_active(0);
|
||||
let uid = s.doc().unwrap().uid;
|
||||
rect(&mut s);
|
||||
rect(&mut s);
|
||||
assert_eq!(objects(&s), 4);
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((s.active_index(), s.documents().len(), objects(&s)), (Some(0), 2, 2));
|
||||
assert!(!st.is_dirty() && st.history.undo.is_empty() && st.selection.is_empty());
|
||||
assert_eq!((st.uid, st.path.as_deref()), (uid, Some(native.as_str())));
|
||||
// With the file gone the document is left as it is, still modified.
|
||||
rect(&mut s);
|
||||
std::fs::remove_file(&native).unwrap();
|
||||
assert!(s.execute("file.revert", &json!({})).is_err());
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.is_dirty());
|
||||
assert_eq!((objects(&s), st.history.undo.len()), (3, 1));
|
||||
// Never saved: nothing to revert to.
|
||||
s.set_active(1);
|
||||
rect(&mut s);
|
||||
assert!(s.execute("file.revert", &json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() {
|
||||
let d = dir("tpl");
|
||||
let mut s = session();
|
||||
let source = path(&d, "flyer.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": source})).unwrap();
|
||||
s.prefs.templates_folder = path(&d, "Templates");
|
||||
let r = s.execute("file.saveAsTemplate", &json!({})).unwrap();
|
||||
assert_eq!(r["name"], "flyer template.vctemplate");
|
||||
assert_eq!(r["folder"].as_str(), Some(s.prefs.templates_folder.as_str()));
|
||||
assert_ne!(r["name"].as_str(), std::path::Path::new(&source).file_name().and_then(|n| n.to_str()), "never the source file");
|
||||
// Writing it leaves the document alone; opening it gives an untitled document.
|
||||
rect(&mut s);
|
||||
let tpl = path(&d, "flyer template.vctemplate");
|
||||
s.execute("file.saveAsTemplate", &json!({"path": tpl})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(source.as_str()));
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
let r = s.execute("document.open", &json!({"path": tpl})).unwrap();
|
||||
assert_eq!(r["format"], "template");
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
assert!(!s.doc().unwrap().doc.template);
|
||||
// New from Template opens any file untitled.
|
||||
let r = s.execute("file.newFromTemplate", &json!({"path": source})).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
// With no preference the folder is under the user's home (where there is one).
|
||||
s.prefs.templates_folder.clear();
|
||||
if let Some(f) = crate::cmd::fileio::templates_folder(&s.prefs) {
|
||||
assert!(f.ends_with("VectorCraft Templates"), "{f}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A native file as an older version wrote it: under the former name, or format version 1.
|
||||
fn old_file(legacy: bool) -> Vec<u8> {
|
||||
let text = String::from_utf8(vectorcraft_format::save(&vectorcraft_doc::Document::new(50.0, 50.0), false)).unwrap();
|
||||
let text = if legacy {
|
||||
text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1)
|
||||
} else {
|
||||
text.replacen(&format!("\"version\":{}", vectorcraft_format::VERSION), "\"version\":1", 1)
|
||||
};
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_native_files_open_converted_and_save_under_a_new_name() {
|
||||
let d = dir("converted");
|
||||
let legacy = path(&d, "logo.drawcraft");
|
||||
std::fs::write(&legacy, old_file(true)).unwrap();
|
||||
let v1 = path(&d, "badge.vectorcraft");
|
||||
std::fs::write(&v1, old_file(false)).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": legacy})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "logo [Converted]");
|
||||
// Save doesn't overwrite the old file: it asks for a .vectorcraft name.
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r.get("path")), (Some("logo.vectorcraft"), None));
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), old_file(true));
|
||||
let r = s.execute("document.open", &json!({"path": v1})).unwrap();
|
||||
assert_eq!(r["title"], "badge [Converted]");
|
||||
let saved = path(&d, "badge2.vectorcraft");
|
||||
s.execute("file.saveAs", &json!({"path": saved})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft");
|
||||
assert!(!s.doc().unwrap().converted);
|
||||
// A current file isn't converted; with the preference off old files open as they are.
|
||||
s.execute("document.open", &json!({"path": saved})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft");
|
||||
s.prefs.append_converted = false;
|
||||
s.execute("document.open", &json!({"path": legacy})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "logo.drawcraft");
|
||||
assert_eq!(s.execute("document.save", &json!({})).unwrap()["path"].as_str(), Some(legacy.as_str()));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_view_round_trips_without_dirtying() {
|
||||
let d = dir("view");
|
||||
let mut s = session();
|
||||
let view = SavedView { name: String::new(), center: Point::new(40.0, 25.0), zoom: 3.0, rotation: 15.0 };
|
||||
s.doc_mut().unwrap().view = Some(view.clone());
|
||||
assert!(!s.doc().unwrap().is_dirty(), "the view isn't an edit");
|
||||
let native = path(&d, "v.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.last_view, None, "only the written file carries it");
|
||||
s.execute("document.open", &json!({"path": native})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().view.as_ref(), Some(&view));
|
||||
// Files without a view (older ones) open with none; SVG doesn't carry it.
|
||||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap();
|
||||
assert_eq!(doc.last_view, None);
|
||||
let svg = path(&d, "v.svg");
|
||||
s.execute("document.save", &json!({"path": svg})).unwrap();
|
||||
s.execute("document.open", &json!({"path": svg})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().view, None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_plans_suggest_names_and_folders() {
|
||||
let mut s = session();
|
||||
s.execute("file.info", &json!({"title": "Map"})).unwrap();
|
||||
let plan = |s: &Session, mode, p: Value| save_plan(s, mode, &p).unwrap();
|
||||
let p = plan(&s, SaveMode::Save, json!({}));
|
||||
assert_eq!((p.path, p.name.as_str(), p.format.id, p.folder), (None, "Map.vectorcraft", "vectorcraft", None));
|
||||
assert_eq!(plan(&s, SaveMode::Copy, json!({"format": "pdf"})).name, "Map copy.pdf");
|
||||
assert_eq!(plan(&s, SaveMode::Template, json!({"format": "svg"})).format.id, "template", "a template is always native");
|
||||
assert_eq!(SaveMode::of("file.saveCopy"), Some(SaveMode::Copy));
|
||||
assert_eq!(crate::cmd::fileio::save_filters("svg")[0], ("SVG", &["svg"][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_color_mode_is_one_command_with_a_hidden_alias() {
|
||||
let mut s = session();
|
||||
let ids: Vec<&str> = s.commands().iter().filter(|c| c.menu.first() == Some(&"File") && c.label.contains("Color Mode")).map(|c| c.id).collect();
|
||||
assert_eq!(ids, ["file.documentColorMode"], "one File menu entry");
|
||||
assert!(crate::cmd::is_alias("object.convertDocumentColorMode") && !crate::cmd::is_alias("file.documentColorMode"));
|
||||
// Both ids run the same conversion; `convert: false` only switches the mode.
|
||||
rect(&mut s);
|
||||
assert!(s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap()["changed"].as_u64().unwrap() >= 1);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("file.documentColorMode", &json!({"mode": "rgb", "convert": false})).unwrap();
|
||||
assert_eq!(r["changed"], 0);
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb);
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "colours untouched");
|
||||
let e = s.execute("file.documentColorMode", &json!({"mode": "lab"})).unwrap_err().to_string();
|
||||
assert!(e.contains("file.documentColorMode"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_the_current_color_mode_is_not_an_edit() {
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.mark_saved();
|
||||
let undo = st.history.undo.len();
|
||||
for id in ["file.documentColorMode", "object.convertDocumentColorMode"] {
|
||||
assert_eq!(s.execute(id, &json!({"mode": "rgb"})).unwrap()["changed"], 0);
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert!(!st.is_dirty(), "the document stays saved");
|
||||
assert_eq!(st.history.undo.len(), undo, "no empty undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_opened_in_part_is_not_saved_back_over_the_whole_file() {
|
||||
let d = dir("pdfpart");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let pdf = path(&d, "two.pdf");
|
||||
s.execute("document.export", &json!({"path": pdf, "format": "pdf"})).unwrap();
|
||||
// The whole file: Save writes it back as PDF.
|
||||
s.execute("document.open", &json!({"path": pdf})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.format), (Some(pdf.as_str()), "pdf"));
|
||||
// One page of it: Save asks for a name instead of dropping the other page.
|
||||
s.execute("document.open", &json!({"path": pdf, "pages": "1"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.format), (None, "vectorcraft"));
|
||||
assert!(s.execute("document.save", &json!({})).unwrap().get("dataBase64").is_some());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_places_like_a_native_file() {
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
let tpl = s.execute("document.serialize", &json!({"format": "template"})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let empty = s.doc().unwrap().doc.node_count();
|
||||
s.execute("file.place", &json!({"name": "card.vctemplate", "dataBase64": tpl["dataBase64"]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.node_count() > empty && !st.selection.is_empty(), "the template's art is placed and selected");
|
||||
}
|
||||
|
|
@ -100,6 +100,27 @@ pub fn save_file(doc: &Document) -> Vec<u8> {
|
|||
}
|
||||
|
||||
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
load_info(bytes).map(|(doc, _)| doc)
|
||||
}
|
||||
|
||||
/// What a native file says about itself besides its document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FileInfo {
|
||||
/// The file's format version (see [`VERSION`]).
|
||||
pub version: u32,
|
||||
/// Written under the project's former name (`"format": "drawcraft"`).
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
/// Written by an older version or under the former name: saving rewrites it in today's format.
|
||||
pub fn is_old(&self) -> bool {
|
||||
self.legacy || self.version < VERSION
|
||||
}
|
||||
}
|
||||
|
||||
/// [`load`], also telling the file's version and name.
|
||||
pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
||||
let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
|
||||
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
||||
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
|
||||
|
|
@ -122,7 +143,7 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
|||
// Saved before per-fill/stroke overprint: the Overprint Black list becomes overprint flags.
|
||||
doc.migrate_overprint_black();
|
||||
doc.fix_next_id();
|
||||
Ok(doc)
|
||||
Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }))
|
||||
}
|
||||
|
||||
/// Does this look like a `.vectorcraft` file?
|
||||
|
|
@ -188,6 +209,20 @@ mod tests {
|
|||
assert!(is_native_name("old/Poster.DrawCraft") && is_native_name("new.vectorcraft") && !is_native_name("x.svg"));
|
||||
assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_info_tells_old_files() {
|
||||
let d = Document::new(10.0, 10.0);
|
||||
let current = save(&d, false);
|
||||
assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false });
|
||||
assert!(!load_info(¤t).unwrap().1.is_old());
|
||||
let text = String::from_utf8(current).unwrap();
|
||||
let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
|
||||
assert!(load_info(legacy.as_bytes()).unwrap().1.is_old());
|
||||
let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1);
|
||||
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false });
|
||||
assert!(load_info(v1.as_bytes()).unwrap().1.is_old());
|
||||
}
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
|
|||
(
|
||||
"file.save",
|
||||
"Save",
|
||||
"{path?, format?} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .svg or .svgz (default: the document's path)",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)",
|
||||
),
|
||||
("file.saveAs", "Save As…", "{path, format?, …the format's options} = document.save"),
|
||||
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
|
||||
("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"),
|
||||
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
|
||||
|
|
@ -106,7 +105,7 @@ impl Headless {
|
|||
fn exec(&mut self, id: &str, params: &Value) -> Result<Value, String> {
|
||||
match id {
|
||||
"file.open" => self.open(params),
|
||||
"file.save" | "file.saveAs" => self.save(params),
|
||||
"file.save" => self.save(params),
|
||||
"file.export" => self.export(params),
|
||||
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
|
||||
"tool.select" => self.select_tool(params),
|
||||
|
|
@ -221,7 +220,8 @@ impl Headless {
|
|||
self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `app.save {path?}`: native format; remembers the path and clears the dirty flag.
|
||||
/// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and
|
||||
/// format); remembers the path and clears the dirty flag.
|
||||
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
if s(p, "path").is_none() && st.path.is_none() {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
|||
let tools = tool_definitions();
|
||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]));
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]));
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::FlattenOptions;
|
||||
use vectorcraft_engine::cmd::flatten::PRESET_FORMAT;
|
||||
use vectorcraft_engine::cmd::flatten::{PRESET_EXTS, PRESET_FORMAT};
|
||||
|
||||
use super::flatten::{options_editor, put_options};
|
||||
use super::{DialogSpec, form};
|
||||
|
|
@ -180,7 +180,8 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
|
|||
f();
|
||||
return Ok(());
|
||||
}
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
|
||||
let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() };
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
||||
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
|
|
@ -274,7 +275,7 @@ mod tests {
|
|||
let file = dir.join("mine.vcflattener").to_string_lossy().to_string();
|
||||
let picked = file.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,9 +12,14 @@ const FIELD_W: f32 = 132.0;
|
|||
|
||||
/// A labelled text field bound to `d.fields[key]` (one grid row).
|
||||
pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
field_w(ui, d, key, label, FIELD_W - 12.0);
|
||||
}
|
||||
|
||||
/// [`field`] `width` points wide.
|
||||
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim));
|
||||
text(ui, d, key, FIELD_W - 12.0);
|
||||
text(ui, d, key, width);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
} else {
|
||||
let path = match d.str("path") {
|
||||
p if !p.is_empty() => p,
|
||||
_ => app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?,
|
||||
_ => {
|
||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
||||
app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
|
||||
// Apply to All: the others by name in the same folder; any not there are asked about.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub mod raster_effects;
|
|||
pub mod recolor;
|
||||
pub mod saturate;
|
||||
mod save_changes;
|
||||
pub(crate) mod save_options;
|
||||
mod save_pdf;
|
||||
pub mod save_style_library;
|
||||
pub mod save_swatch_library;
|
||||
|
|
@ -201,6 +202,7 @@ registry! {
|
|||
TextImport: [text_import::KIND] => text_import::SPEC,
|
||||
PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC,
|
||||
PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC,
|
||||
SaveOptions: [save_options::KIND] => save_options::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -175,7 +175,8 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
|
|||
f();
|
||||
return Ok(());
|
||||
}
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
|
||||
let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() };
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
||||
let r = app.run("pdf.preset.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
|
|
@ -295,7 +296,7 @@ mod tests {
|
|||
let file = dir.join("mine.vcpdfpresets").to_string_lossy().to_string();
|
||||
let picked = file.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
//! A save format's options before the file is written; on the web, where there is no save panel,
|
||||
//! also the Save As dialog that names the file and picks the format (SVG and PDF then go on to
|
||||
//! their own dialogs). The rows come from the format's options in the engine's format table, so a
|
||||
//! format gains rows as its encoder gains options.
|
||||
//!
|
||||
//! Fields: `__action` (the save command it finishes), `__pick` (file name and format
|
||||
//! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{self, Format, FormatOption, SAVE_FORMATS, SaveMode};
|
||||
|
||||
use super::{DialogResult, DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
/// Dialog kind.
|
||||
pub const KIND: &str = "saveOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
if d.bool("__pick") {
|
||||
return match SaveMode::of(&d.str("__action")) {
|
||||
Some(SaveMode::Copy) => "Save a Copy",
|
||||
Some(SaveMode::Template) => "Save as Template",
|
||||
_ => "Save As",
|
||||
}
|
||||
.into();
|
||||
}
|
||||
format!("{} Options", fileio::format(&d.str("format")).map_or("", |f| f.label))
|
||||
}
|
||||
|
||||
/// `f`'s options with the values a save would use (`file.formatOptions`: as last saved in that
|
||||
/// format, else the defaults). Text options start empty rather than null.
|
||||
pub(crate) fn option_values(app: &mut VectorcraftApp, f: &Format) -> Map<String, Value> {
|
||||
let v = app.session.execute("file.formatOptions", &json!({ "format": f.id })).unwrap_or_default();
|
||||
f.options
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let value = v["options"][o.name]["value"].clone();
|
||||
(o.name.to_string(), if o.ty == "string" && value.is_null() { json!("") } else { value })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Formats whose options have a dialog of their own (SVG Options, Save PDF), which follows this
|
||||
/// one.
|
||||
fn own_dialog(f: &Format) -> bool {
|
||||
matches!(f.id, "svg" | "svgz" | "pdf")
|
||||
}
|
||||
|
||||
/// The options the dialog shows: arrays and objects are for agents, and a page range replaces the
|
||||
/// single artboard choice; none for a format with its own dialog.
|
||||
fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> {
|
||||
let range = f.options.iter().any(|o| o.name == "range");
|
||||
let options = if own_dialog(f) { &[][..] } else { f.options };
|
||||
options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard")))
|
||||
}
|
||||
|
||||
/// The Save As format menu (labels in [`SAVE_FORMATS`] order).
|
||||
fn save_labels() -> &'static [&'static str] {
|
||||
static LABELS: OnceLock<Vec<&'static str>> = OnceLock::new();
|
||||
LABELS.get_or_init(|| SAVE_FORMATS.iter().filter_map(|id| fileio::format(id)).map(|f| f.label).collect())
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(f) = fileio::format(&d.str("format")) else { return false };
|
||||
let mut switch = None;
|
||||
egui::Grid::new("save-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
if d.bool("__pick") {
|
||||
form::field_w(ui, d, "path", "File Name:", 240.0);
|
||||
if SaveMode::of(&d.str("__action")) != Some(SaveMode::Template) {
|
||||
ui.label(egui::RichText::new("Format:").color(t.text_dim));
|
||||
switch = widgets::dropdown(ui, "save-format", f.label, save_labels(), 254.0).and_then(|i| SAVE_FORMATS.get(i).copied());
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for o in shown(f) {
|
||||
option_row(app, ui, d, o);
|
||||
}
|
||||
});
|
||||
if let Some(id) = switch.filter(|id| *id != f.id) {
|
||||
switch_format(app, d, id);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// One option: a checkbox, an artboard menu, a number or a text field.
|
||||
fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = form::humanize(o.name);
|
||||
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
|
||||
if o.ty == "boolean" {
|
||||
ui.label("");
|
||||
let mut on = value.as_bool().unwrap_or(false);
|
||||
if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() {
|
||||
d.fields.insert(o.name.into(), json!(on));
|
||||
}
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
if o.ty == "string" {
|
||||
form::field_w(ui, d, o.name, &label, 240.0);
|
||||
return;
|
||||
}
|
||||
ui.label(egui::RichText::new(&label).color(t.text_dim)).on_hover_text(o.description);
|
||||
let artboards = app.session.active().filter(|_| o.name == "artboard").map(|st| &st.doc.artboards);
|
||||
if let Some(boards) = artboards {
|
||||
let i = value.as_u64().unwrap_or(0) as usize;
|
||||
let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) {
|
||||
d.fields.insert(o.name.into(), json!(i));
|
||||
}
|
||||
} else {
|
||||
let integer = o.ty == "integer";
|
||||
let mut x = value.as_f64().unwrap_or(0.0);
|
||||
let drag =
|
||||
egui::DragValue::new(&mut x).range(0.0..=f64::MAX).speed(if integer { 1.0 } else { 0.1 }).max_decimals(if integer { 0 } else { 2 });
|
||||
if ui.add(drag).on_hover_text(o.description).changed() {
|
||||
d.fields.insert(o.name.into(), if integer { json!(x.round() as i64) } else { json!(x) });
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// Web Save As: another format brings its own options and file extension.
|
||||
fn switch_format(app: &mut VectorcraftApp, d: &mut Dialog, id: &str) {
|
||||
let (Some(old), Some(new)) = (fileio::format(&d.str("format")), fileio::format(id)) else { return };
|
||||
for o in old.options {
|
||||
d.fields.remove(o.name);
|
||||
}
|
||||
d.fields.extend(option_values(app, new));
|
||||
d.fields.insert("format".into(), json!(new.id));
|
||||
d.fields.insert("path".into(), json!(with_extension(&d.str("path"), new)));
|
||||
}
|
||||
|
||||
/// `path` ending in `f`'s extension (kept when it already names `f`).
|
||||
fn with_extension(path: &str, f: &Format) -> String {
|
||||
if fileio::format_for_name(path).is_some_and(|g| g.id == f.id) {
|
||||
return path.to_string();
|
||||
}
|
||||
std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Write the file with the chosen options (blank ones fall back to the format's defaults).
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
||||
let f = fileio::format(&d.str("format")).ok_or("unknown format")?;
|
||||
let path = d.str("path");
|
||||
if path.trim().is_empty() {
|
||||
return Err("name the file".into());
|
||||
}
|
||||
let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path };
|
||||
let options: Map<String, Value> = f
|
||||
.options
|
||||
.iter()
|
||||
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
|
||||
.collect();
|
||||
app.ui.dialog = None;
|
||||
match SaveMode::of(&d.str("__action")) {
|
||||
Some(mode) if own_dialog(f) => io::ask_format_options(app, mode, f, &path),
|
||||
Some(mode) => io::save(app, mode, &json!({ "path": path, "format": f.id, "options": options }), false),
|
||||
None => Err(format!("`{}` is no save command", d.str("__action"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::{FilePick, Services, theme};
|
||||
|
||||
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// A desktop app whose save panel answers `picked` and records what it was shown.
|
||||
fn desktop(picked: &'static str) -> (VectorcraftApp, Written, Rc<RefCell<Vec<FilePick>>>) {
|
||||
let (written, picks) = (Written::default(), Rc::new(RefCell::new(vec![])));
|
||||
let (w, p) = (written.clone(), picks.clone());
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(move |f: &FilePick| {
|
||||
p.borrow_mut().push(f.clone());
|
||||
Some(picked.to_string())
|
||||
})),
|
||||
write: Some(Box::new(move |path: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((path.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(with_art(services), written, picks)
|
||||
}
|
||||
|
||||
/// A browser app: saves are downloads.
|
||||
fn web() -> (VectorcraftApp, Written) {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))),
|
||||
..Default::default()
|
||||
};
|
||||
(with_art(services), written)
|
||||
}
|
||||
|
||||
fn with_art(services: Services) -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer; `enter` presses OK.
|
||||
fn frame(app: &mut VectorcraftApp, enter: bool) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let events = if enter {
|
||||
vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn dialog(app: &VectorcraftApp) -> &Dialog {
|
||||
app.ui.dialog.as_ref().expect("a dialog is open")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_svg_opens_svg_options_then_writes_svg() {
|
||||
let (mut app, written, picks) = desktop("art.svg");
|
||||
let r = app.run("file.saveAs", json!({})).unwrap();
|
||||
assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg")));
|
||||
// The save panel offers every save format, the document's own first.
|
||||
let pick = &picks.borrow()[0];
|
||||
let labels: Vec<&str> = pick.filters.iter().map(|f| f.0).collect();
|
||||
assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]);
|
||||
assert_eq!(pick.name, "Untitled-1.vectorcraft");
|
||||
let d = dialog(&app);
|
||||
assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save"));
|
||||
assert!(written.borrow().is_empty(), "nothing is written before OK");
|
||||
frame(&mut app, false);
|
||||
assert!(app.ui.dialog.is_some(), "drawing keeps it open");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true));
|
||||
frame(&mut app, true);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let (path, bytes) = written.borrow()[0].clone();
|
||||
assert_eq!(path, "art.svg");
|
||||
assert!(bytes.starts_with(b"<?xml"));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some("art.svg"), "svg", false));
|
||||
assert_eq!((&st.save_options["outlineText"], &st.save_options["styling"]), (&json!(true), &json!("css")), "the SVG Options are remembered");
|
||||
assert!(app.ui.status.contains("SVG keeps the artwork"), "{}", app.ui.status);
|
||||
// Save writes it back as SVG, with the same options and no questions.
|
||||
assert!(!crate::menus::enabled(&app, "file.save"), "nothing to save");
|
||||
app.run("shape.ellipse", json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
assert!(crate::menus::enabled(&app, "file.save"));
|
||||
app.run("file.save", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.is_none() && picks.borrow().len() == 1);
|
||||
assert!(written.borrow()[1].1.starts_with(b"<?xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_a_copy_and_template_suggest_their_names() {
|
||||
let (mut app, written, picks) = desktop("copy.vectorcraft");
|
||||
app.session.prefs.templates_folder = "/templates".into();
|
||||
app.run("file.saveCopy", json!({})).unwrap();
|
||||
assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft");
|
||||
assert!(app.ui.dialog.is_none(), "the native format has no options");
|
||||
assert!(vectorcraft_format::sniff(&written.borrow()[0].1));
|
||||
assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty());
|
||||
app.run("file.saveAsTemplate", json!({})).unwrap();
|
||||
let pick = &picks.borrow()[1];
|
||||
assert_eq!((pick.name.as_str(), pick.folder.as_deref()), ("Untitled-1 template.vctemplate", Some("/templates")));
|
||||
assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..])]);
|
||||
assert_eq!(written.borrow()[1].0, "copy.vectorcraft", "the picked name is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_save_as_names_the_file_and_picks_the_format() {
|
||||
let (mut app, written) = web();
|
||||
app.run("file.saveAs", json!({})).unwrap();
|
||||
let d = dialog(&app);
|
||||
assert_eq!((heading(d).as_str(), d.str("path").as_str(), d.str("format").as_str()), ("Save As", "Untitled-1.vectorcraft", "vectorcraft"));
|
||||
frame(&mut app, false);
|
||||
let mut d = app.ui.dialog.take().unwrap();
|
||||
switch_format(&mut app, &mut d, "pdf");
|
||||
assert_eq!((d.str("path").as_str(), d.str("format").as_str()), ("Untitled-1.pdf", "pdf"));
|
||||
assert_eq!(shown(fileio::format("pdf").unwrap()).count(), 0, "PDF options have their own dialog");
|
||||
app.ui.dialog = Some(d);
|
||||
frame(&mut app, true);
|
||||
// OK goes on to the Save PDF dialog, whose OK saves.
|
||||
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("path").as_str()), ("savePdf", "Untitled-1.pdf"));
|
||||
assert!(written.borrow().is_empty());
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let (name, bytes) = written.borrow()[0].clone();
|
||||
assert_eq!(name, "Untitled-1.pdf");
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
assert_eq!(app.session.active().unwrap().format, "pdf");
|
||||
// A blank name isn't saved.
|
||||
app.run("file.saveCopy", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(" "));
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() {
|
||||
let (mut app, written, _) = desktop("art.pdf");
|
||||
assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], "savePdf");
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.str("path").as_str(), d.str("__save").as_str()), ("art.pdf", "file.saveAs"));
|
||||
d.fields.insert("compatibility".into(), json!("1.5"));
|
||||
d.fields.insert("createLayers".into(), json!(true));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(written.borrow()[0].1.starts_with(b"%PDF-1.5"));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some("art.pdf"), "pdf", false));
|
||||
assert_eq!((st.save_options["compatibility"].clone(), st.save_options["createLayers"].clone()), (json!("1.5"), json!(true)));
|
||||
// Save writes the PDF again with those options, without asking.
|
||||
app.run("shape.ellipse", json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
app.run("file.save", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.is_none() && written.borrow()[1].1.starts_with(b"%PDF-1.5"));
|
||||
// Save a Copy as PDF starts from them.
|
||||
app.run("file.saveCopy", json!({"format": "pdf"})).unwrap();
|
||||
assert_eq!(dialog(&app).str("compatibility"), "1.5");
|
||||
assert_eq!(dialog(&app).str("__save"), "file.saveCopy");
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use std::sync::LazyLock;
|
|||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_engine::cmd::fileio::pdf;
|
||||
use vectorcraft_engine::cmd::fileio::{SaveMode, pdf};
|
||||
use vectorcraft_pdf::{
|
||||
Changes, Choice, ColorConversion, Compatibility, Downsample, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings, Printing,
|
||||
ProfileInclusion, Standard,
|
||||
|
|
@ -100,8 +100,9 @@ fn params(d: &Dialog) -> Value {
|
|||
p
|
||||
}
|
||||
|
||||
/// Save PDF: write the file; the dialog stays open when that fails (bad range, cancelled save…).
|
||||
/// While Save Preset… asks for a name, OK (and Enter) saves the preset instead.
|
||||
/// Save PDF: write the file (a PDF copy, or the PDF a Save As / Save a Copy asked for, `__save`);
|
||||
/// the dialog stays open when that fails (bad range, cancelled save…). While Save Preset… asks for
|
||||
/// a name, OK (and Enter) saves the preset instead.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
if d.fields.contains_key(SAVE_AS) {
|
||||
let mut d = d.clone();
|
||||
|
|
@ -109,7 +110,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
app.ui.dialog = Some(d);
|
||||
return r;
|
||||
}
|
||||
let r = io::export_pdf(app, params(d));
|
||||
let r = match SaveMode::of(&d.str("__save")) {
|
||||
Some(mode) => io::save_pdf(app, mode, params(d)),
|
||||
None => io::export_pdf(app, params(d)),
|
||||
};
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
|
|
@ -759,9 +763,9 @@ mod tests {
|
|||
// Without a path, bad options are refused before a save dialog asks for one.
|
||||
let asked = Rc::new(RefCell::new(0));
|
||||
let a = asked.clone();
|
||||
app.services.pick_save = Some(Box::new(move |name: &str| {
|
||||
app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| {
|
||||
*a.borrow_mut() += 1;
|
||||
Some(format!("/tmp/picked-{name}"))
|
||||
Some(format!("/tmp/picked-{}", pick.name))
|
||||
}));
|
||||
app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap();
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
|
|
|
|||
|
|
@ -78,7 +78,12 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) {
|
|||
if app.session.active().is_some_and(|st| st.doc.setup.export_text == vectorcraft_doc::ExportText::Appearance) {
|
||||
fields.insert("outlineText".into(), json!(true));
|
||||
}
|
||||
let saved = app.session.active().map(|st| st.save_options.clone()).filter(|_| mode != Mode::Export);
|
||||
// Only options saved with an SVG format are SVG options.
|
||||
let saved = app
|
||||
.session
|
||||
.active()
|
||||
.filter(|st| mode != Mode::Export && matches!(st.format, "svg" | "svgz"))
|
||||
.map(|st| Value::Object(st.save_options.clone()));
|
||||
for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) {
|
||||
if let Some(o) = last.as_object() {
|
||||
fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
|
|
@ -289,7 +294,7 @@ mod tests {
|
|||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ fn app(artboards: usize) -> (VectorcraftApp, Written) {
|
|||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(|name: &str| Some(format!("/out/{name}")))),
|
||||
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
|
|
@ -211,7 +211,7 @@ fn old_ids_and_agents_export_directly_with_a_path() {
|
|||
#[test]
|
||||
fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() {
|
||||
let (mut app, written) = app(1);
|
||||
app.services.pick_save = Some(Box::new(|_: &str| panic!("no save dialog without art")));
|
||||
app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art")));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("edit.clear", json!({})).unwrap();
|
||||
app.run("file.exportAs", json!({})).unwrap();
|
||||
|
|
@ -219,7 +219,7 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() {
|
|||
assert_eq!(kind(&app), Some("exportAs"));
|
||||
// With Use Artboards the empty artboard is exported.
|
||||
set(&mut app, "useArtboards", json!(true));
|
||||
app.services.pick_save = Some(Box::new(|name: &str| Some(format!("/out/{name}"))));
|
||||
app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name))));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(kind(&app), Some("pngOptions"));
|
||||
assert!(written.borrow().is_empty());
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
//! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
|
||||
//! decodes and encodes every format.
|
||||
//! File I/O through the injected services (pick, read, write, download, reveal); the engine's
|
||||
//! `fileio` decodes, encodes and saves every format.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_doc::SavedView;
|
||||
use vectorcraft_engine::EngineError;
|
||||
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::dialogs::svg_options;
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, Services, VectorcraftApp, dialogs};
|
||||
|
||||
/// Template extensions New from Template's open dialog lists first.
|
||||
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
|
||||
|
||||
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
|
||||
/// graphic style library files open in the library panel and flattener and PDF presets files are
|
||||
|
|
@ -49,48 +55,107 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A path from the open dialog, or "cancelled".
|
||||
fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||
app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
|
||||
/// File → Open…
|
||||
pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
|
||||
if let Some(f) = app.services.open_async.as_mut() {
|
||||
f();
|
||||
return Ok(());
|
||||
}
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
|
||||
let path = pick_open(app, &FilePick { filters: fileio::open_filters().collect(), ..Default::default() })?;
|
||||
open_path(app, &path)
|
||||
}
|
||||
|
||||
fn read(app: &VectorcraftApp, path: &str) -> Result<Vec<u8>, String> {
|
||||
app.services.read.as_ref().ok_or("no file reader")?(path)
|
||||
}
|
||||
|
||||
pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
|
||||
let read = app.services.read.as_ref().ok_or("no file reader")?;
|
||||
let bytes = read(path)?;
|
||||
let bytes = read(app, path)?;
|
||||
open_bytes(app, path, &bytes, Some(path.to_string()))?;
|
||||
note_recent(app, path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_out(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dl) = app.services.download.as_mut() {
|
||||
/// File → New from Template…: a template (or any readable file) as a new untitled document. Without
|
||||
/// a path the open dialog starts in the Templates folder. On the web the browser's file picker
|
||||
/// opens it (`.vctemplate`, `.ait` and template files open untitled there too).
|
||||
pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Result<Value, String> {
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null),
|
||||
None => {
|
||||
let filters = std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters()).collect();
|
||||
pick_open(app, &FilePick { folder: fileio::templates_folder(&app.session.prefs), filters, ..Default::default() })?
|
||||
}
|
||||
};
|
||||
let bytes = read(app, &path)?;
|
||||
let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?;
|
||||
app.sync_views();
|
||||
crate::dialogs::missing_links::after_open(app, &r);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
|
||||
fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dl) = services.download.as_mut() {
|
||||
dl(&fileio::file_name(path), bytes);
|
||||
return Ok(());
|
||||
}
|
||||
let w = app.services.write.as_mut().ok_or("no file writer")?;
|
||||
w(path, bytes)
|
||||
services.write.as_mut().ok_or("no file writer")?(path, bytes)
|
||||
}
|
||||
|
||||
fn suggested(app: &VectorcraftApp, ext: &str) -> String {
|
||||
let t = app.session.active().map(|d| d.title()).unwrap_or_else(|| "Untitled".into());
|
||||
let stem = std::path::Path::new(&t).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(t);
|
||||
format!("{stem}.{ext}")
|
||||
/// The web saves by downloading: no save panel, no folders.
|
||||
fn is_web(app: &VectorcraftApp) -> bool {
|
||||
app.services.download.is_some()
|
||||
}
|
||||
|
||||
/// `path`, else a suggested name (web download) or one picked in a save dialog.
|
||||
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
|
||||
/// save panel's choice.
|
||||
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||
if is_web(app) {
|
||||
return Ok(pick.name.clone());
|
||||
}
|
||||
app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
|
||||
/// The active document's file name with extension `ext`, and its folder.
|
||||
fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option<String>) {
|
||||
let st = app.session.active();
|
||||
let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into());
|
||||
let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string());
|
||||
(format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty()))
|
||||
}
|
||||
|
||||
/// `path`, else one picked in a save panel (the web: the suggested name) for the active document
|
||||
/// with extension `ext`, filtered to that format when it is one.
|
||||
pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
|
||||
match path {
|
||||
Some(p) => Ok(p),
|
||||
None if app.services.download.is_some() => Ok(suggested(app, ext)),
|
||||
None => {
|
||||
let s = suggested(app, ext);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
if let Some(p) = path {
|
||||
return Ok(p);
|
||||
}
|
||||
let (name, folder) = suggested(app, ext);
|
||||
let filters = fileio::format(ext).map(|f| vec![(f.label, f.extensions)]).unwrap_or_default();
|
||||
pick_path(app, &FilePick { name, folder, filters })
|
||||
}
|
||||
|
||||
/// Keep the active document's saved view current before a save (native files reopen at it).
|
||||
pub fn remember_view(app: &mut VectorcraftApp) {
|
||||
let Some(v) = app.view().copied().filter(|v| v.fitted) else { return };
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation });
|
||||
}
|
||||
}
|
||||
|
||||
/// A path typed in a save panel: kept when its extension names a save format (the panel's file
|
||||
/// type), else `f`'s extension is added.
|
||||
fn with_save_extension(path: &str, f: &Format) -> String {
|
||||
match fileio::format_for_name(path) {
|
||||
Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(),
|
||||
_ => format!("{path}.{}", f.extensions[0]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,78 +164,156 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &
|
|||
fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
|
||||
let named = enc.named(doc, path);
|
||||
for (p, bytes) in &named {
|
||||
write_out(app, p, bytes)?;
|
||||
write_to(&mut app.services, p, bytes)?;
|
||||
}
|
||||
Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect())
|
||||
}
|
||||
|
||||
fn format_param(p: &Value) -> Option<&str> {
|
||||
p.get("format").and_then(Value::as_str)
|
||||
fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, String> {
|
||||
fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// File → Save / Save As: native, SVG for an .svg path (with the SVG options in `params`, else
|
||||
/// the ones the document was last saved with), or a PDF-compatible .ai file (with the PDF options
|
||||
/// in `params`) that reopens editable.
|
||||
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, params: &Value) -> Result<String, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let existing = if save_as { None } else { st.path.clone() };
|
||||
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
|
||||
let f = fileio::save_format(format_param(params), Some(&path))?;
|
||||
fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?);
|
||||
let params = fileio::pdf::expand_preset(&app.session, "file.save", params).map_err(|e| e.to_string())?;
|
||||
let params = &*params;
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let (enc, opts) = fileio::save_encoding(st, f, params, Some(&path)).map_err(|e| e.to_string())?;
|
||||
let doc = st.doc.clone();
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(path.clone());
|
||||
st.save_options = opts;
|
||||
st.mark_saved();
|
||||
}
|
||||
app.status(format!("Saved {path}"));
|
||||
note_recent(app, &path);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// File → Save As / Save a Copy: the path (asked for when missing); an SVG path without SVG
|
||||
/// options in `p` opens SVG Options, whose OK saves. A copy leaves the document's path alone.
|
||||
pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result<Value, String> {
|
||||
let path = target_path(app, p.get("path").and_then(Value::as_str).map(str::to_string), vectorcraft_format::EXTENSION)?;
|
||||
let f = fileio::save_format(format_param(p), Some(&path))?;
|
||||
let given = p.get("svg").is_some_and(|v| !v.is_null()) || !fileio::svg_options(p)?.is_empty();
|
||||
if matches!(f.id, "svg" | "svgz") && !given {
|
||||
let mode = if copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };
|
||||
svg_options::open(app, mode, Some(&path));
|
||||
return Ok(json!({ "dialog": svg_options::KIND, "path": path }));
|
||||
}
|
||||
if !copy {
|
||||
return save(app, Some(path), true, p).map(|p| json!({ "path": p }));
|
||||
}
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let doc = st.doc.clone();
|
||||
// A .ai copy is written as Save writes it (PDF plus the native document).
|
||||
let enc = match f.id {
|
||||
"ai" => fileio::pdf::expand_preset(&app.session, "file.saveCopy", p)
|
||||
.and_then(|p| fileio::save_encoding(st, f, &p, Some(&path)))
|
||||
.map(|(enc, _)| enc),
|
||||
_ => {
|
||||
let copy = vectorcraft_engine::cmd::links::with_relative_paths(&doc, &path);
|
||||
fileio::encode_all(copy.as_ref().unwrap_or(&doc), f.id, p)
|
||||
/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the
|
||||
/// engine command's `{path?, format?, options?, svg?}`.
|
||||
///
|
||||
/// When no path is known, desktop asks with a save panel (one file type per save format) and the
|
||||
/// web opens the Save As dialog (file name and format). With `ask_options` and no options in `p`,
|
||||
/// a format picked that way that has options asks for them before anything is written (SVG
|
||||
/// Options, the Save PDF dialog, or the save options dialog), and so does Save As or Save a Copy
|
||||
/// to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
|
||||
/// path?}` while a dialog is open.
|
||||
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result<Value, String> {
|
||||
remember_view(app);
|
||||
let first = plan(app, mode, p)?;
|
||||
let ask = ask_options && !has_options(p);
|
||||
if let Some(path) = first.path.clone() {
|
||||
if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") {
|
||||
return ask_format_options(app, mode, first.format, &path);
|
||||
}
|
||||
return write_plan(app, first);
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
app.status(format!("Saved a copy as {path}"));
|
||||
Ok(json!({ "path": path }))
|
||||
if is_web(app) && ask_options {
|
||||
return Ok(open_options(app, mode.command(), first.format, &first.name, true));
|
||||
}
|
||||
let filters = match mode {
|
||||
SaveMode::Template => vec![(first.format.label, first.format.extensions)],
|
||||
_ => fileio::save_filters(first.format.id),
|
||||
};
|
||||
let picked = pick_path(app, &FilePick { name: first.name.clone(), folder: first.folder.clone(), filters })?;
|
||||
// The picked file type wins over a `format` param.
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("format");
|
||||
o.insert("path".into(), json!(with_save_extension(&picked, first.format)));
|
||||
}
|
||||
let chosen = plan(app, mode, &q)?;
|
||||
if ask && !chosen.format.options.is_empty() {
|
||||
let path = chosen.path.clone().unwrap_or_default();
|
||||
return ask_format_options(app, mode, chosen.format, &path);
|
||||
}
|
||||
write_plan(app, chosen)
|
||||
}
|
||||
|
||||
/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files).
|
||||
/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)?
|
||||
fn has_options(p: &Value) -> bool {
|
||||
["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty())
|
||||
}
|
||||
|
||||
/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save
|
||||
/// PDF dialog for PDF, else the save options dialog. OK finishes the save.
|
||||
pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result<Value, String> {
|
||||
match f.id {
|
||||
"pdf" => ask_pdf_options(app, mode, path),
|
||||
"svg" | "svgz" => {
|
||||
let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };
|
||||
svg_options::open(app, m, Some(path));
|
||||
Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path }))
|
||||
}
|
||||
_ => Ok(open_options(app, mode.command(), f, path, false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last
|
||||
/// saved with; its OK finishes the save ([`save_pdf`]).
|
||||
pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result<Value, String> {
|
||||
let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options);
|
||||
p["path"] = json!(path);
|
||||
dialogs::open_save_pdf(app, &p)?;
|
||||
let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?;
|
||||
d.fields.insert("__save".into(), json!(mode.command()));
|
||||
Ok(json!({ "pending": d.kind }))
|
||||
}
|
||||
|
||||
/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options
|
||||
/// and `path`.
|
||||
pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result<Value, String> {
|
||||
let path = params.as_object_mut().and_then(|o| o.remove("path"));
|
||||
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false);
|
||||
let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?;
|
||||
if view && let Some(path) = r["path"].as_str() {
|
||||
view_file(app, path);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Open a written file in the system viewer (not on the web, which downloads it).
|
||||
fn view_file(app: &mut VectorcraftApp, path: &str) {
|
||||
if !is_web(app) {
|
||||
app.open_url(&file_url(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// "Saved <path>", with the first of the result's warnings.
|
||||
fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) {
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
app.status(match warnings.first() {
|
||||
Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()),
|
||||
None => format!("Saved {path}"),
|
||||
});
|
||||
}
|
||||
|
||||
/// Write a planned save through the services and report it in the status bar.
|
||||
fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> {
|
||||
let retargets = plan.retargets();
|
||||
let services = &mut app.services;
|
||||
let r = fileio::save_with(&mut app.session, plan, |path, bytes| write_to(services, path, bytes).map_err(EngineError::Other))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||||
if retargets {
|
||||
note_recent(app, &path);
|
||||
}
|
||||
report_saved(app, &path, &r);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Open the save options dialog of format `f` before writing `path`: `action` is the save command
|
||||
/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format.
|
||||
fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value {
|
||||
let mut fields = dialogs::save_options::option_values(app, f);
|
||||
fields.insert("__action".into(), json!(action));
|
||||
fields.insert("__pick".into(), json!(pick));
|
||||
fields.insert("path".into(), json!(path));
|
||||
fields.insert("format".into(), json!(f.id));
|
||||
app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields });
|
||||
json!({ "pending": dialogs::save_options::KIND })
|
||||
}
|
||||
|
||||
/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display
|
||||
/// shows 0 to this many of them.
|
||||
pub const MAX_RECENT_FILES: usize = 30;
|
||||
|
||||
/// Put `path` at the top of File → Open Recent Files.
|
||||
pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
|
||||
let r = &mut app.ui.recent_files;
|
||||
r.retain(|p| p != path);
|
||||
r.insert(0, path.to_string());
|
||||
r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10));
|
||||
r.truncate(MAX_RECENT_FILES);
|
||||
}
|
||||
|
||||
/// The recent files File → Open Recent Files lists.
|
||||
pub fn recent_files(app: &VectorcraftApp) -> &[String] {
|
||||
let r = &app.ui.recent_files;
|
||||
&r[..r.len().min(app.session.prefs.recent_files_count as usize)]
|
||||
}
|
||||
|
||||
/// Export the active document in `format` (default: the path's extension, else PNG) with the
|
||||
|
|
@ -239,7 +382,7 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value)
|
|||
Some(p) => p,
|
||||
None => target_path(app, None, ext)?,
|
||||
};
|
||||
write_out(app, &path, &bytes)?;
|
||||
write_to(&mut app.services, &path, &bytes)?;
|
||||
Ok((path, v))
|
||||
}
|
||||
|
||||
|
|
@ -250,14 +393,10 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value)
|
|||
pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false);
|
||||
let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?;
|
||||
if view && app.services.download.is_none() {
|
||||
app.open_url(&file_url(&path));
|
||||
if view {
|
||||
view_file(app, &path);
|
||||
}
|
||||
let warnings: Vec<&str> = v["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
app.status(match warnings.first() {
|
||||
Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()),
|
||||
None => format!("Saved {path}"),
|
||||
});
|
||||
report_saved(app, &path, &v);
|
||||
v["path"] = Value::String(path);
|
||||
Ok(v)
|
||||
}
|
||||
|
|
@ -278,6 +417,23 @@ fn file_url(path: &str) -> String {
|
|||
url
|
||||
}
|
||||
|
||||
/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine.
|
||||
pub fn ask_revert(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?;
|
||||
(c.enabled)(&app.session)?;
|
||||
let name = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||||
let message = format!("Revert to the saved version of “{name}”?");
|
||||
dialogs::confirm::ask(app, &message, "Changes made since it was last saved will be lost.", "file.revert", json!({ "confirmed": true }));
|
||||
Ok(json!({ "pending": dialogs::confirm::KIND }))
|
||||
}
|
||||
|
||||
/// File → Show in Folder: the document's file in the system file manager.
|
||||
pub fn reveal(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?;
|
||||
app.services.reveal.as_mut().ok_or("no file manager here")?(&path)?;
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`.
|
||||
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())
|
||||
|
|
@ -385,7 +541,7 @@ mod tests {
|
|||
let (mut app, written) = app();
|
||||
app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
|
||||
app.services.pick_save = Some(Box::new(|_: &str| Some("/tmp/sel.svg".into())));
|
||||
app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into())));
|
||||
assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg");
|
||||
app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap();
|
||||
let w = written.borrow();
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ mod tests_place;
|
|||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
#[cfg(test)]
|
||||
mod tests_svgsave;
|
||||
|
|
@ -63,13 +65,32 @@ use std::sync::mpsc::{Receiver, Sender};
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
pub use state::{UiState, View};
|
||||
pub use sysclip::SystemClipboard;
|
||||
|
||||
pub type PickSave = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the
|
||||
/// file-type filters.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FilePick {
|
||||
pub name: String,
|
||||
pub folder: Option<String>,
|
||||
/// `(label, extensions without the dot)`, the default type first; empty = every file.
|
||||
pub filters: Vec<(&'static str, &'static [&'static str])>,
|
||||
}
|
||||
|
||||
impl FilePick {
|
||||
/// A save dialog suggesting `name`.
|
||||
pub fn named(name: &str) -> Self {
|
||||
Self { name: name.to_string(), ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
pub type PickOpen = Box<dyn FnMut(&FilePick) -> Option<String>>;
|
||||
pub type PickSave = Box<dyn FnMut(&FilePick) -> Option<String>>;
|
||||
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
|
@ -78,12 +99,15 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
|||
/// Opens a URL in the system browser.
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
|
||||
|
||||
/// Shows a file in the system file manager.
|
||||
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
|
||||
/// Platform services injected by the host app (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Show an open dialog; returns a path.
|
||||
pub pick_open: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// Show a save dialog with a suggested file name; returns a path.
|
||||
pub pick_open: Option<PickOpen>,
|
||||
/// Show a save dialog (suggested name, folder, file types); returns a path.
|
||||
pub pick_save: Option<PickSave>,
|
||||
pub read: Option<ReadFn>,
|
||||
pub write: Option<WriteFn>,
|
||||
|
|
@ -109,6 +133,8 @@ pub struct Services {
|
|||
/// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through
|
||||
/// egui and `clipboard_read`).
|
||||
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
|
||||
/// File → Show in Folder: select a file in the system file manager (desktop).
|
||||
pub reveal: Option<RevealFn>,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
|
|
@ -201,7 +227,7 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
|
|||
|
||||
impl VectorcraftApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
let views = session.documents().iter().map(|_| View::default()).collect();
|
||||
let views = session.documents().iter().map(View::of).collect();
|
||||
Self {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
|
|
@ -248,13 +274,11 @@ impl VectorcraftApp {
|
|||
self
|
||||
}
|
||||
|
||||
/// Keep `views` aligned with the session's documents.
|
||||
/// Keep `views` aligned with the session's documents (a new one starts at its saved view).
|
||||
pub fn sync_views(&mut self) {
|
||||
let n = self.session.documents().len();
|
||||
while self.views.len() < n {
|
||||
self.views.push(View::default());
|
||||
}
|
||||
self.views.truncate(n);
|
||||
let docs = self.session.documents();
|
||||
self.views.truncate(docs.len());
|
||||
self.views.extend(docs.iter().skip(self.views.len()).map(View::of));
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Option<&View> {
|
||||
|
|
@ -302,6 +326,10 @@ impl VectorcraftApp {
|
|||
return r;
|
||||
}
|
||||
}
|
||||
// Native files carry the view they reopen at.
|
||||
if fileio::SaveMode::of(id).is_some() {
|
||||
io::remember_view(self);
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") {
|
||||
self.publish_clipboard();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use vectorcraft_engine::cmd::fileio::SaveMode;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::io;
|
||||
use crate::state::{DockTab, ICON_PANELS, next_zoom};
|
||||
|
|
@ -52,22 +54,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"file.save",
|
||||
"Save",
|
||||
"Cmd+S",
|
||||
"{path?, svg?: {…SVG options}, …PDF options for .ai} (a document saved as .svg saves as SVG again, with the same options; one saved or opened as .ai saves as .ai again)",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first",
|
||||
),
|
||||
(
|
||||
"file.saveAs",
|
||||
"Save As…",
|
||||
"Cmd+Shift+S",
|
||||
"{path?, svg?: {…SVG options}, …document.exportPdf options for .ai} .vectorcraft, .ai (PDF-compatible: a PDF carrying the native document, which reopens editable) or .svg/.svgz (such a path without svg options opens SVG Options)",
|
||||
),
|
||||
(
|
||||
"file.saveCopy",
|
||||
"Save a Copy…",
|
||||
"Cmd+Alt+S",
|
||||
"{path?, svg?: {…SVG options}, …PDF options for .ai} like Save As, but the document keeps its path",
|
||||
),
|
||||
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
|
||||
("file.revert", "Revert", "F12", "{}"),
|
||||
(
|
||||
"file.place",
|
||||
"Place…",
|
||||
|
|
@ -416,6 +404,27 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"",
|
||||
"{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets",
|
||||
),
|
||||
("file.openRecent11", "Open Recent File 11", "", "{}"),
|
||||
("file.openRecent12", "Open Recent File 12", "", "{}"),
|
||||
("file.openRecent13", "Open Recent File 13", "", "{}"),
|
||||
("file.openRecent14", "Open Recent File 14", "", "{}"),
|
||||
("file.openRecent15", "Open Recent File 15", "", "{}"),
|
||||
("file.openRecent16", "Open Recent File 16", "", "{}"),
|
||||
("file.openRecent17", "Open Recent File 17", "", "{}"),
|
||||
("file.openRecent18", "Open Recent File 18", "", "{}"),
|
||||
("file.openRecent19", "Open Recent File 19", "", "{}"),
|
||||
("file.openRecent20", "Open Recent File 20", "", "{}"),
|
||||
("file.openRecent21", "Open Recent File 21", "", "{}"),
|
||||
("file.openRecent22", "Open Recent File 22", "", "{}"),
|
||||
("file.openRecent23", "Open Recent File 23", "", "{}"),
|
||||
("file.openRecent24", "Open Recent File 24", "", "{}"),
|
||||
("file.openRecent25", "Open Recent File 25", "", "{}"),
|
||||
("file.openRecent26", "Open Recent File 26", "", "{}"),
|
||||
("file.openRecent27", "Open Recent File 27", "", "{}"),
|
||||
("file.openRecent28", "Open Recent File 28", "", "{}"),
|
||||
("file.openRecent29", "Open Recent File 29", "", "{}"),
|
||||
("file.openRecent30", "Open Recent File 30", "", "{}"),
|
||||
("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -440,36 +449,23 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
|
||||
None => io::open_dialog(app).map(|_| Value::Null),
|
||||
},
|
||||
"file.save" => io::save(app, s("path"), false, p).map(|p| json!({"path": p})),
|
||||
"file.saveAs" | "file.saveCopy" => io::save_as(app, id == "file.saveCopy", p),
|
||||
// Saves write through the app (save panel, download); with no path known the panel and the
|
||||
// format's options dialog ask first.
|
||||
"file.save" => io::save(app, SaveMode::Save, p, true),
|
||||
"file.saveAs" => io::save(app, SaveMode::SaveAs, p, true),
|
||||
"file.saveCopy" => io::save(app, SaveMode::Copy, p, true),
|
||||
"file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true),
|
||||
"document.exportSelection" => {
|
||||
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
|
||||
}
|
||||
"file.saveAsTemplate" => {
|
||||
io::save_command_output(app, id, "vectorcraft", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
|
||||
}
|
||||
"file.newFromTemplate" => match s("path") {
|
||||
// Bytes sent by an agent go straight to the engine; a path or nothing opens it here.
|
||||
"file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")),
|
||||
"file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app),
|
||||
"file.reveal" => io::reveal(app),
|
||||
id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() {
|
||||
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
|
||||
None => io::open_dialog(app).map(|_| Value::Null),
|
||||
None => Err("no such recent file".into()),
|
||||
},
|
||||
"file.revert" => {
|
||||
let path = app.session.active().and_then(|d| d.path.clone());
|
||||
match path {
|
||||
Some(path) => {
|
||||
let i = app.session.active_index().unwrap_or(0);
|
||||
app.session.close_document(i);
|
||||
io::open_path(app, &path).map(|_| Value::Null)
|
||||
}
|
||||
None => Err("document has never been saved".into()),
|
||||
}
|
||||
}
|
||||
id if id.starts_with("file.openRecent") => {
|
||||
let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0);
|
||||
match n.checked_sub(1).and_then(|i| app.ui.recent_files.get(i)).cloned() {
|
||||
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
|
||||
None => Err("no such recent file".into()),
|
||||
}
|
||||
}
|
||||
"type.findFont" => {
|
||||
crate::find_font::open(app);
|
||||
Ok(Value::Null)
|
||||
|
|
@ -478,7 +474,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
app.ui.recent_files.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.recentFiles" => Ok(json!(app.ui.recent_files)),
|
||||
"file.recentFiles" => Ok(json!(io::recent_files(app))),
|
||||
"file.place" => crate::place::run(app, p),
|
||||
"file.place.queue" => crate::place::queue(app, p),
|
||||
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
|
|
@ -879,13 +875,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
|||
"view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(),
|
||||
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
|
||||
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
|
||||
id if id.starts_with("file.openRecent") => {
|
||||
let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0);
|
||||
n.checked_sub(1)
|
||||
.and_then(|i| app.ui.recent_files.get(i))
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into())
|
||||
}
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id)
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
|
||||
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
|
||||
|
|
@ -898,14 +890,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Menu slots that are left out while they have nothing to show (unused saved views, User
|
||||
/// Defined swatch and graphic style libraries).
|
||||
/// Menu slots that are left out while they have nothing to show (unused saved views, recent files,
|
||||
/// User Defined swatch and graphic style libraries).
|
||||
fn hidden_when_disabled(id: &str) -> bool {
|
||||
id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT)
|
||||
id.starts_with("view.goto")
|
||||
|| id.starts_with("file.openRecent")
|
||||
|| id.starts_with(crate::panels::swatches::USER_SLOT)
|
||||
|| id.starts_with(crate::panels::graphic_styles::USER_SLOT)
|
||||
}
|
||||
|
||||
/// File → Open Recent Files slots.
|
||||
const RECENT_IDS: [&str; 10] = [
|
||||
/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them).
|
||||
const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [
|
||||
"file.openRecent1",
|
||||
"file.openRecent2",
|
||||
"file.openRecent3",
|
||||
|
|
@ -916,8 +911,49 @@ const RECENT_IDS: [&str; 10] = [
|
|||
"file.openRecent8",
|
||||
"file.openRecent9",
|
||||
"file.openRecent10",
|
||||
"file.openRecent11",
|
||||
"file.openRecent12",
|
||||
"file.openRecent13",
|
||||
"file.openRecent14",
|
||||
"file.openRecent15",
|
||||
"file.openRecent16",
|
||||
"file.openRecent17",
|
||||
"file.openRecent18",
|
||||
"file.openRecent19",
|
||||
"file.openRecent20",
|
||||
"file.openRecent21",
|
||||
"file.openRecent22",
|
||||
"file.openRecent23",
|
||||
"file.openRecent24",
|
||||
"file.openRecent25",
|
||||
"file.openRecent26",
|
||||
"file.openRecent27",
|
||||
"file.openRecent28",
|
||||
"file.openRecent29",
|
||||
"file.openRecent30",
|
||||
];
|
||||
|
||||
/// The recent file a `file.openRecentN` slot names (none past the preference's count).
|
||||
fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> {
|
||||
let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?;
|
||||
io::recent_files(app).get(n.checked_sub(1)?)
|
||||
}
|
||||
|
||||
/// A menu slot hidden while it is empty ([`hidden_when_disabled`]) rather than shown disabled (the
|
||||
/// native menu leaves it out).
|
||||
pub fn hidden_slot(app: &VectorcraftApp, id: &str) -> bool {
|
||||
hidden_when_disabled(id) && !enabled(app, id)
|
||||
}
|
||||
|
||||
/// How many saved-view, recent-file and User Defined library slots are listed: a menu that can't
|
||||
/// hide items (the native one) is rebuilt when this changes.
|
||||
pub fn listed_slots(app: &VectorcraftApp) -> usize {
|
||||
use vectorcraft_engine::cmd::{stylelib, swatchlib};
|
||||
let user = |libs: Vec<swatchlib::LibraryInfo>| libs.iter().filter(|l| l.category == "user").count().min(10);
|
||||
let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10));
|
||||
views + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session))
|
||||
}
|
||||
|
||||
/// Type → Recent Fonts slots.
|
||||
const RECENT_FONT_IDS: [&str; 10] = [
|
||||
"type.recentFont1",
|
||||
|
|
@ -944,10 +980,10 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste");
|
||||
}
|
||||
match id {
|
||||
"file.save"
|
||||
| "file.saveAs"
|
||||
| "file.saveCopy"
|
||||
| "file.place"
|
||||
// Save is off for a clean document that already has its own file.
|
||||
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
|
||||
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
|
||||
"file.place"
|
||||
| "file.export.svg"
|
||||
| "file.export.png"
|
||||
| "file.exportAs"
|
||||
|
|
@ -958,10 +994,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
| "view.fitArtboard"
|
||||
| "view.fitAll"
|
||||
| "view.actualSize" => app.session.active().is_some(),
|
||||
"file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()),
|
||||
id if id.starts_with("file.openRecent") => {
|
||||
id["file.openRecent".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && n <= app.ui.recent_files.len())
|
||||
}
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(),
|
||||
"file.clearRecent" => !app.ui.recent_files.is_empty(),
|
||||
id if id.starts_with("type.recentFont") => {
|
||||
id["type.recentFont".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some()
|
||||
|
|
@ -1015,6 +1048,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
v.push(c("Clear Recent Files", "file.clearRecent"));
|
||||
v
|
||||
}),
|
||||
c("Show in Folder", "file.reveal"),
|
||||
Sep,
|
||||
c("Close", "file.close"),
|
||||
c("Close All", "file.closeAll"),
|
||||
|
|
@ -1022,6 +1056,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("Save As…", "file.saveAs"),
|
||||
c("Save a Copy…", "file.saveCopy"),
|
||||
c("Save as Template…", "file.saveAsTemplate"),
|
||||
c("Save as PDF…", "file.export.pdf"),
|
||||
c("Revert", "file.revert"),
|
||||
Sep,
|
||||
c("Place…", "file.place"),
|
||||
|
|
@ -1045,8 +1080,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
sub(
|
||||
"Document Color Mode",
|
||||
vec![
|
||||
cp("CMYK Color", "object.convertDocumentColorMode", json!({"mode": "cmyk"})),
|
||||
cp("RGB Color", "object.convertDocumentColorMode", json!({"mode": "rgb"})),
|
||||
cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})),
|
||||
cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})),
|
||||
],
|
||||
),
|
||||
c("File Info…", "file.info"),
|
||||
|
|
@ -1659,7 +1694,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
|
|||
}
|
||||
Item::Cmd(label, id, p) => {
|
||||
let en = enabled(app, id);
|
||||
// Unused saved-view slots are hidden (Illustrator lists only the saved views).
|
||||
// Unused saved-view and recent-file slots are hidden (only the real ones are listed).
|
||||
if !en && hidden_when_disabled(id) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ use serde_json::json;
|
|||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, menus};
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.palette_open {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let q = app.ui.palette_query.to_lowercase();
|
||||
let mut items: Vec<(String, String, String)> = vec![]; // (label, kind/id, shortcut)
|
||||
for c in vectorcraft_engine::command_specs() {
|
||||
/// Everything the palette searches: (label, command id or `tool:<id>`, shortcut).
|
||||
pub fn items() -> Vec<(String, String, String)> {
|
||||
let mut items = vec![];
|
||||
// Aliases kept for older scripts run a command that is listed already.
|
||||
for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) {
|
||||
let path = c.menu.join(" › ");
|
||||
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
|
||||
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
|
||||
|
|
@ -23,6 +20,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
for tool in vectorcraft_tools::catalog::all_tools() {
|
||||
items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string()));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.palette_open {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let q = app.ui.palette_query.to_lowercase();
|
||||
let items = items();
|
||||
let matches: Vec<&(String, String, String)> = items
|
||||
.iter()
|
||||
.filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w)))
|
||||
|
|
|
|||
|
|
@ -141,7 +141,12 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option<String>)
|
|||
f();
|
||||
return Ok(None);
|
||||
}
|
||||
path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).map(Some).ok_or_else(|| "cancelled".into())
|
||||
path.or_else(|| {
|
||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() };
|
||||
app.services.pick_open.as_mut().and_then(|f| f(&pick))
|
||||
})
|
||||
.map(Some)
|
||||
.ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
|
||||
/// The User Defined library a Window menu slot (`prefix` and a number from 1) stands for, of
|
||||
|
|
@ -557,7 +562,7 @@ mod tests {
|
|||
let written = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
|
||||
let w = written.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))),
|
||||
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -107,7 +107,11 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::flat_button(ui, "Load Profile…", 110.0).clicked()
|
||||
&& let Some(path) = app.services.pick_open.as_mut().and_then(|f| f())
|
||||
&& let Some(path) = app
|
||||
.services
|
||||
.pick_open
|
||||
.as_mut()
|
||||
.and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() }))
|
||||
{
|
||||
run(app, "color.loadProfile", json!({ "path": path }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ fn saving_to_a_file_and_opening_a_vcstyles_file() {
|
|||
let written = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
|
||||
let w = written.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))),
|
||||
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
|
|||
}
|
||||
let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) {
|
||||
(Some(f), _) => f(),
|
||||
(None, Some(f)) => f().into_iter().collect(),
|
||||
(None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(),
|
||||
(None, None) => vec![],
|
||||
};
|
||||
if paths.is_empty() {
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
|||
"shortcuts.export" => {
|
||||
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
|
||||
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
|
||||
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f("VectorCraft Shortcuts.json")));
|
||||
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json"))));
|
||||
match path {
|
||||
Some(path) => match app.services.write.as_mut() {
|
||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
|
||||
|
|
@ -461,7 +461,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
|||
let data = if let Some(v) = p.get("data") {
|
||||
Ok(v.clone())
|
||||
} else {
|
||||
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f()));
|
||||
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f(&crate::FilePick::default())));
|
||||
match (path, app.services.read.as_ref()) {
|
||||
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
|
||||
(None, _) => Err("cancelled".into()),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ impl Default for View {
|
|||
}
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The view a document opens at: the one it was saved with, else fitted on first display.
|
||||
pub fn of(st: &vectorcraft_engine::DocState) -> Self {
|
||||
match &st.view {
|
||||
Some(v) if v.zoom.is_finite() && v.zoom > 0.0 && v.center.x.is_finite() && v.center.y.is_finite() => Self {
|
||||
zoom: v.zoom.clamp(0.0313, 640.0),
|
||||
center: v.center,
|
||||
fitted: true,
|
||||
rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 },
|
||||
},
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Illustrator's preset zoom stops, in percent.
|
||||
pub const ZOOM_STOPS: [f64; 26] = [
|
||||
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ fn app() -> VectorcraftApp {
|
|||
/// File → Place… with `path` chosen in the file picker.
|
||||
fn place_picked(app: &mut VectorcraftApp, path: &str) {
|
||||
let path = path.to_string();
|
||||
app.services.pick_open = Some(Box::new(move || Some(path.clone())));
|
||||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(path.clone())));
|
||||
app.run("file.place", json!({})).unwrap();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
//! The File menu around saving, in the app: Revert's confirmation, recent files, Show in Folder,
|
||||
//! the single Document Color Mode entry and documents reopening at their saved view.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Services, VectorcraftApp, dialogs, io, menus, palette, theme};
|
||||
|
||||
/// An app that reads and writes real files.
|
||||
fn app() -> VectorcraftApp {
|
||||
let services = Services {
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
VectorcraftApp::new(Session::new(), services)
|
||||
}
|
||||
|
||||
/// A fresh folder for one test.
|
||||
fn dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-ui-save-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn rect(app: &mut VectorcraftApp) {
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
}
|
||||
|
||||
fn objects(app: &VectorcraftApp) -> usize {
|
||||
app.session.doc().unwrap().doc.node_count()
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer with Enter pressed (OK).
|
||||
fn press_ok(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
let mut out = ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_asks_then_reloads_in_the_same_tab_keeping_every_zoom() {
|
||||
let d = dir("revert");
|
||||
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
rect(&mut app);
|
||||
app.run("file.saveAs", json!({"path": path})).unwrap();
|
||||
assert!(!menus::enabled(&app, "file.revert"), "nothing changed since the save");
|
||||
app.run("file.new", json!({"width": 50, "height": 50})).unwrap();
|
||||
app.views[0].zoom = 2.0;
|
||||
app.views[1].zoom = 4.0;
|
||||
app.session.set_active(0);
|
||||
rect(&mut app);
|
||||
assert!(menus::enabled(&app, "file.revert"));
|
||||
let r = app.run("file.revert", json!({})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
assert_eq!(objects(&app), 3, "nothing happens before OK");
|
||||
press_ok(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!((objects(&app), app.session.active_index(), app.session.documents().len()), (2, Some(0), 2));
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
assert_eq!((app.views[0].zoom, app.views[1].zoom), (2.0, 4.0));
|
||||
// Cancel leaves the changes.
|
||||
rect(&mut app);
|
||||
app.run("file.revert", json!({})).unwrap();
|
||||
app.ui.dialog = None;
|
||||
assert_eq!(objects(&app), 3);
|
||||
// An untitled document can't revert.
|
||||
app.session.set_active(1);
|
||||
rect(&mut app);
|
||||
assert!(app.run("file.revert", json!({})).is_err() && app.ui.dialog.is_none());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_from_template_takes_bytes_from_agents() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let bytes = app.session.execute("document.serialize", &json!({"format": "template"})).unwrap();
|
||||
let r = app.run("file.newFromTemplate", json!({"name": "flyer.vctemplate", "dataBase64": bytes["dataBase64"]})).unwrap();
|
||||
assert!(r["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{r}");
|
||||
assert_eq!((app.session.documents().len(), app.views.len()), (2, 2));
|
||||
assert_eq!(app.session.active().unwrap().path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_show_as_many_as_the_preference_keeps() {
|
||||
let mut app = app();
|
||||
for i in 0..35 {
|
||||
io::note_recent(&mut app, &format!("/art/{i}.svg"));
|
||||
}
|
||||
let listed = |app: &VectorcraftApp| {
|
||||
menus::menu_entries(app).iter().filter(|e| e.command.as_deref().is_some_and(|c| c.starts_with("file.openRecent"))).count()
|
||||
};
|
||||
assert_eq!((io::recent_files(&app).len(), listed(&app)), (20, 20), "the default");
|
||||
assert_eq!(menus::dynamic_label(&app, "file.openRecent1", ""), "34.svg");
|
||||
app.session.prefs.recent_files_count = 30;
|
||||
assert_eq!((io::recent_files(&app).len(), listed(&app)), (30, 30));
|
||||
assert!(menus::enabled(&app, "file.openRecent30"));
|
||||
app.session.prefs.recent_files_count = 0;
|
||||
assert_eq!((io::recent_files(&app).len(), listed(&app)), (0, 0), "0 hides the list");
|
||||
assert_eq!(menus::listed_slots(&app), 0, "the native menu rebuilds without them");
|
||||
assert!(app.run("file.openRecent1", json!({})).is_err());
|
||||
app.session.prefs.recent_files_count = 5;
|
||||
assert_eq!(io::recent_files(&app)[0], "/art/34.svg", "hiding them didn't forget them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_in_folder_needs_a_saved_document_and_a_file_manager() {
|
||||
let shown = Rc::new(RefCell::new(vec![]));
|
||||
let s = shown.clone();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(!menus::enabled(&app, "file.reveal"));
|
||||
app.session.doc_mut().unwrap().path = Some("/art/a.vectorcraft".into());
|
||||
assert!(!menus::enabled(&app, "file.reveal"), "no file manager (web)");
|
||||
app.services.reveal = Some(Box::new(move |p: &str| {
|
||||
s.borrow_mut().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
assert!(menus::enabled(&app, "file.reveal"));
|
||||
assert_eq!(app.run("file.reveal", json!({})).unwrap()["path"], "/art/a.vectorcraft");
|
||||
assert_eq!(*shown.borrow(), ["/art/a.vectorcraft"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_palette_lists_document_color_mode_once() {
|
||||
let items = palette::items();
|
||||
let ids: Vec<&str> = items.iter().map(|(_, id, _)| id.as_str()).filter(|id| id.to_ascii_lowercase().contains("documentcolormode")).collect();
|
||||
assert_eq!(ids, ["file.documentColorMode"]);
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let modes: Vec<&str> =
|
||||
entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Document Color Mode")).filter_map(|e| e.command.as_deref()).collect();
|
||||
assert_eq!(modes, ["file.documentColorMode", "file.documentColorMode"], "CMYK and RGB run the one command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_reopens_at_its_saved_view() {
|
||||
let d = dir("view");
|
||||
let path = d.join("v.vectorcraft").to_string_lossy().to_string();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.zoom, v.center, v.rotation, v.fitted) = (3.0, Point::new(70.0, 40.0), 30.0, true);
|
||||
}
|
||||
app.run("file.saveAs", json!({"path": path})).unwrap();
|
||||
// Moving around afterwards isn't an edit.
|
||||
app.view_mut().unwrap().zoom = 0.5;
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
io::open_path(&mut app, &path).unwrap();
|
||||
let v = *app.view().unwrap();
|
||||
assert_eq!((v.zoom, v.center, v.rotation, v.fitted), (3.0, Point::new(70.0, 40.0), 30.0, true));
|
||||
// A document without a saved view is fitted on first display.
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(!app.view().unwrap().fitted);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ pub(super) fn app(picked: &str) -> (VectorcraftApp, Written) {
|
|||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|||
let i = i.ok_or("no such document")?;
|
||||
if !d.bool("discard") {
|
||||
app.session.set_active(i);
|
||||
io::save(app, None, false, &Value::Null)?;
|
||||
io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
|
||||
}
|
||||
close_now(app, i)?;
|
||||
match d.str("then").as_str() {
|
||||
|
|
@ -91,7 +91,7 @@ mod tests {
|
|||
let written = Arc::new(Mutex::new(vec![]));
|
||||
let w = written.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(|_: &str| Some("out.vectorcraft".to_string()))),
|
||||
pick_save: Some(Box::new(|_: &crate::FilePick| Some("out.vectorcraft".to_string()))),
|
||||
write: Some(Box::new(move |p: &str, _: &[u8]| {
|
||||
w.lock().unwrap().push(p.to_string());
|
||||
Ok(())
|
||||
|
|
@ -154,7 +154,7 @@ mod tests {
|
|||
#[test]
|
||||
fn a_cancelled_save_as_keeps_the_document_open() {
|
||||
let (mut app, _) = app();
|
||||
app.services.pick_save = Some(Box::new(|_: &str| None));
|
||||
app.services.pick_save = Some(Box::new(|_: &crate::FilePick| None));
|
||||
new_doc(&mut app, true);
|
||||
close(&mut app, 0).unwrap();
|
||||
assert!(confirm(&mut app).is_err());
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
|
||||
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
|
||||
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
|
||||
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
|
||||
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
|
||||
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
|
||||
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
|
||||
|
|
@ -283,3 +284,10 @@ lists to the system clipboard, and every Paste command first loads what another
|
|||
`importPdf`, `importText` or `importImage`, centred in the view); an import error answers the Paste with that error
|
||||
and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no
|
||||
Paste event for it).
|
||||
|
||||
File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference
|
||||
lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the
|
||||
system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker
|
||||
in the Templates folder (preference `templatesFolder`). Older native files open as "<name> [Converted]" (preference
|
||||
`appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved
|
||||
with.
|
||||
|
|
|
|||
12
docs/mcp.md
12
docs/mcp.md
|
|
@ -63,7 +63,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
|||
| `open_panel` | `{panel}` | Remote only. |
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. |
|
||||
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
|
|
@ -139,6 +139,16 @@ hidden layers. A `preserveEditing` SVG carries the native document (CDATA in `<m
|
|||
markup around it: if another app changed the SVG since, `document.open` reads it as plain SVG and says so in
|
||||
`warnings`.
|
||||
|
||||
Saving beyond `save_file` goes through `run_command`: `file.saveAs {path?, format?, options?, svg?}` (the document takes
|
||||
on the new file and format), `file.saveCopy` (the document keeps its path, title and modified state),
|
||||
`file.saveAsTemplate` (a `.vctemplate` that opens untitled; the suggested name and the Templates folder come back
|
||||
without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options
|
||||
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF
|
||||
remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part
|
||||
of the file, so Save asks for a new name instead of writing it back.
|
||||
`file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours
|
||||
through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts).
|
||||
|
||||
## Resources
|
||||
|
||||
| URI | Content |
|
||||
|
|
|
|||
Loading…
Reference in New Issue