mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 14:20:24 +00:00
Merge pull request #6785 from dotCooCoo/fix/read-roots-save-export-results
MCP: save, export and package replies leave out paths outside a confined client's read roots
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
|
||||
//! Package), and Object → Make Pixel Perfect.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut images: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut image_rows: Vec<ImageRow> = vec![];
|
||||
// Whether the request being served may read each linked file (a relative path read from the
|
||||
// document's folder), checked once per file.
|
||||
let mut readable: HashMap<String, bool> = HashMap::new();
|
||||
let link_doc = super::links::LinkBase::of(st).doc;
|
||||
let mut may_read = |stored: &str| {
|
||||
*readable.entry(stored.to_string()).or_insert_with(|| {
|
||||
let resolved = super::fileio::document_path(stored, link_doc).ok();
|
||||
crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok()
|
||||
})
|
||||
};
|
||||
let mut patterns = BTreeSet::new();
|
||||
let mut symbols = BTreeSet::new();
|
||||
let mut spot = BTreeSet::new();
|
||||
@@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("textObjects")
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
// A linked file outside a confined request's read roots: `link` is null, and the row
|
||||
// names the file alone.
|
||||
let link = im.link.as_ref().map(|l| (l, may_read(&l.path)));
|
||||
images.insert(
|
||||
im.key.clone(),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }),
|
||||
);
|
||||
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
|
||||
image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() })));
|
||||
Some("images")
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
|
||||
@@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
|
||||
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
|
||||
templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
|
||||
reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
|
||||
@@ -124,7 +124,9 @@ pub(crate) use access::list_dir;
|
||||
pub use access::write_file_with;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file};
|
||||
pub(crate) use access::{
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) use access::{document_path_on, document_path_with};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
/// May a reply to the request being served name `path`, a file or folder the client did not give?
|
||||
/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]).
|
||||
pub(crate) fn shown(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name,
|
||||
/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such
|
||||
/// as `..` or a folder ending in a separator).
|
||||
pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
shown_link(path, None)
|
||||
}
|
||||
|
||||
/// A link's `stored` path as a reply to the request being served names it: whole when the file it
|
||||
/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it.
|
||||
pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> {
|
||||
if shown(resolved.unwrap_or(stored)) {
|
||||
return std::borrow::Cow::Borrowed(stored);
|
||||
}
|
||||
let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default();
|
||||
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset",
|
||||
always,
|
||||
screens::presets_list
|
||||
),
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
|
||||
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
screens::presets_export
|
||||
),
|
||||
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens Settings",
|
||||
[],
|
||||
None,
|
||||
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)",
|
||||
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)",
|
||||
has_doc,
|
||||
screens::export_settings
|
||||
),
|
||||
|
||||
@@ -1034,11 +1034,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let out = job.write_streamed(&mut super::write_file_with)?;
|
||||
let mut out = job.write_streamed(&mut super::write_file_with)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The reply `out` of a save with the `path` param `given`, as the request being served may see it
|
||||
/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is
|
||||
/// left out, and the `files` and `linked` written beside it are named by their file names, when they
|
||||
/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it
|
||||
/// is outside them. Without roots in force `out` is unchanged.
|
||||
pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) {
|
||||
use super::access::{shown, shown_path};
|
||||
|
||||
if crate::file_access::current().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(o) = out.as_object_mut() else { return };
|
||||
if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) {
|
||||
o.remove("folder");
|
||||
}
|
||||
// Every path the reply names then is the one the client gave, or beside it.
|
||||
if given.is_some_and(|g| !g.is_empty()) {
|
||||
return;
|
||||
}
|
||||
if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) {
|
||||
o.remove("path");
|
||||
}
|
||||
for key in ["files", "linked"] {
|
||||
if let Some(Value::Array(rows)) = o.get_mut(key) {
|
||||
for row in rows {
|
||||
if let Value::String(p) = row
|
||||
&& !shown(p)
|
||||
{
|
||||
*row = json!(shown_path(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
match (&st.path, st.is_dirty()) {
|
||||
@@ -1125,7 +1161,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is 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, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is 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, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -644,12 +644,86 @@ impl Output {
|
||||
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
|
||||
/// the params remembered in the document (`document.exportSettings`).
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A folder the client gave, as against the one its `exportPreset` keeps.
|
||||
let given = p.get("folder").is_some();
|
||||
let p = with_preset(s, p)?;
|
||||
let r = export(s, &p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
Some(folder) => leave_out_folder_from_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
remember(s, &p);
|
||||
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
|
||||
leave_out_unreadable_folder(&mut r, folder);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error of an export into `folder`, a folder outside the read roots that the client did not
|
||||
/// give: `folder` as the path the error is about names it by its own name
|
||||
/// ([`super::access::shown_path`]), and the rest of the message stays.
|
||||
fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
|
||||
let name = super::access::shown_path(folder);
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) },
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
|
||||
/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or
|
||||
/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
|
||||
/// names stays whole, even one inside `folder`.
|
||||
fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(folder)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
|
||||
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
|
||||
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
|
||||
/// as the reply of an export to a ZIP names them.
|
||||
fn leave_out_unreadable_folder(r: &mut Value, folder: &str) {
|
||||
if super::access::shown(folder) {
|
||||
return;
|
||||
}
|
||||
let Some(o) = r.as_object_mut() else { return };
|
||||
o.remove("path");
|
||||
let inside = format!("{folder}/");
|
||||
if let Some(Value::Array(files)) = o.get_mut("files") {
|
||||
for file in files {
|
||||
// A ZIP's files are named relative to it already.
|
||||
if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) {
|
||||
*file = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export settings kept in the document or a preset, as the request being served may see them: a
|
||||
/// `folder` outside the read roots ([`super::access::shown`]) is left out.
|
||||
fn leave_out_unreadable_settings_folder(mut settings: Map<String, Value>) -> Map<String, Value> {
|
||||
if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) {
|
||||
settings.remove("folder");
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
// ---------- export presets ----------
|
||||
|
||||
/// A saved batch export: what to write and how to name and sort it, to use again
|
||||
@@ -727,7 +801,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value {
|
||||
}
|
||||
|
||||
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
|
||||
let saved: Vec<ScreenExportPreset> = s
|
||||
.prefs
|
||||
.screen_export_presets
|
||||
.iter()
|
||||
.map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) })
|
||||
.collect();
|
||||
Ok(json!({ "presets": preset_list(&saved) }))
|
||||
}
|
||||
|
||||
/// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would
|
||||
@@ -831,6 +911,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(too_many());
|
||||
}
|
||||
let count = presets.len();
|
||||
let presets: Vec<ScreenExportPreset> =
|
||||
presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect();
|
||||
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?;
|
||||
if text.len() > MAX_PRESET_FILE_BYTES {
|
||||
return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
|
||||
@@ -1388,12 +1470,31 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map<String, Value>)
|
||||
|
||||
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
|
||||
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "settings": s.doc()?.doc.export_settings }))
|
||||
Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, ScreenSize, safe_file_name};
|
||||
use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
|
||||
|
||||
/// A hidden export folder is named by its name where a message is about it, and a root that
|
||||
/// starts with the same text, or a longer path, stays whole.
|
||||
#[test]
|
||||
fn a_hidden_folder_is_named_by_its_name_in_a_message() {
|
||||
let f = "/work/out";
|
||||
let cases = [
|
||||
(
|
||||
"automation path rejected: outside the write root /work/out/in: /work/out",
|
||||
"automation path rejected: outside the write root /work/out/in: out",
|
||||
),
|
||||
("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"),
|
||||
("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"),
|
||||
("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
|
||||
];
|
||||
for (msg, want) in cases {
|
||||
assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Files given one name are numbered in turn, each numbering going on from the last one (so
|
||||
/// thousands of them don't each try every number used before), and a file asking again for
|
||||
|
||||
@@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\`
|
||||
/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole.
|
||||
#[test]
|
||||
fn a_hidden_path_shows_as_its_file_name_on_every_platform() {
|
||||
let windows = "C:\\Users\\someone\\art\\red.png";
|
||||
crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || {
|
||||
for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] {
|
||||
assert_eq!(shown_path(path), name, "{path}");
|
||||
}
|
||||
});
|
||||
assert_eq!(shown_path(windows), windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_every_readable_format() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
|
||||
@@ -667,6 +667,118 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() {
|
||||
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
|
||||
}
|
||||
|
||||
/// An export into the folder an export preset keeps, for a client whose read roots don't hold that
|
||||
/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder
|
||||
/// inside the read roots, and one the client gives, stay whole.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("preset-roots");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap();
|
||||
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| confine(roots.as_ref(), || export(s, p));
|
||||
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"}));
|
||||
assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}");
|
||||
assert!(out.join("1x/Artboard-1.png").is_file());
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}));
|
||||
assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}");
|
||||
assert!(!r.to_string().contains(&folder), "{r}");
|
||||
// Inside the read roots, or given by the client: whole.
|
||||
assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")]));
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}));
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}");
|
||||
}
|
||||
|
||||
/// An export into the folder an export preset keeps, refused because that folder is outside the
|
||||
/// client's read and write roots: the error names the folder by its name only. A folder the client
|
||||
/// gives stays whole in the error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("preset-refused");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string();
|
||||
|
||||
let e = run(json!({"exportPreset": "Kept"}));
|
||||
assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}");
|
||||
let e = run(json!({"exportPreset": "Kept", "folder": folder}));
|
||||
assert!(e.contains(&folder), "{e}");
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written");
|
||||
// A preset folder that holds the write root: the root stays whole in the error.
|
||||
let parent = tmp.0.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": parent});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap();
|
||||
let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string();
|
||||
let root = inside.display().to_string();
|
||||
let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(e.contains(&format!("root {root}: {name}")), "{e}");
|
||||
}
|
||||
|
||||
/// The export settings the document keeps and the saved presets, read by a client whose read roots
|
||||
/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and
|
||||
/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest.
|
||||
/// Inside the read roots the folder stays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn kept_settings_leave_out_a_folder_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("settings-roots");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "svg"}], "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
export(&mut s, json!({"exportPreset": "Kept"}));
|
||||
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap();
|
||||
let ask = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, cmd: &str, p: Value| {
|
||||
confine(roots.as_ref(), || s.execute(cmd, &p).unwrap())
|
||||
};
|
||||
let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone();
|
||||
|
||||
let r = ask(&mut s, &confined, "document.exportSettings", json!({}));
|
||||
assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}");
|
||||
let r = ask(&mut s, &confined, "document.exportPresets.list", json!({}));
|
||||
assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}");
|
||||
let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]}));
|
||||
let data = r["data"].as_str().unwrap();
|
||||
assert!(!data.contains(&folder) && data.contains("svg"), "{data}");
|
||||
let file = inside.join("kept.vcexportpresets");
|
||||
let path = file.to_string_lossy().replace('\\', "/");
|
||||
ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path}));
|
||||
assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder));
|
||||
|
||||
// Inside the read roots: whole.
|
||||
assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder));
|
||||
assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder));
|
||||
assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_settings_apply_to_their_rows_and_rows_win() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file};
|
||||
use super::links::LinkBase;
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package",
|
||||
[],
|
||||
None,
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder. The warnings and the report name a file outside the automation read roots by its file name",
|
||||
has_doc,
|
||||
package
|
||||
)]
|
||||
@@ -178,8 +178,10 @@ impl<'a> Collector<'a> {
|
||||
|
||||
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
|
||||
/// copy of `source` finds it only by its name, beside it.
|
||||
/// `path` is the link's path as the warning names it.
|
||||
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
|
||||
if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) {
|
||||
let source = shown_path(source);
|
||||
let message = format!(
|
||||
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
|
||||
);
|
||||
@@ -207,11 +209,13 @@ impl<'a> Collector<'a> {
|
||||
let section = self.sections.len();
|
||||
self.sections.push(vec![]);
|
||||
let mut lines = vec![];
|
||||
// The packaged document's sections read as before; a placed document's name its file.
|
||||
// The packaged document's sections read as before; a placed document's name its file. The
|
||||
// report and the warnings name a file outside a confined request's read roots by its name.
|
||||
let shown_source = shown_path(source);
|
||||
let heading = |lines: &mut Vec<String>, title: &str| {
|
||||
lines.push(title.into());
|
||||
if depth > 0 {
|
||||
lines.push(format!("Document: {source}"));
|
||||
lines.push(format!("Document: {shown_source}"));
|
||||
}
|
||||
};
|
||||
// A file's saved path is only meaningful inside its own document.
|
||||
@@ -243,17 +247,21 @@ impl<'a> Collector<'a> {
|
||||
heading(&mut lines, "LINKED FILES");
|
||||
for file in linked_files(doc) {
|
||||
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
||||
// The stored path, whole when the place it names (read from this document's folder) is
|
||||
// inside the read roots. A file found elsewhere does not show a stored path outside them.
|
||||
let resolved = document_path(&file.path, Some(source)).ok();
|
||||
let shown = shown_link(&file.path, resolved.as_deref()).into_owned();
|
||||
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
|
||||
// The ancestor is already included (possibly the root document). A
|
||||
// cyclic reference cannot have a final content hash until both files
|
||||
// have been written, so leave its size/hash unspecified, not stale.
|
||||
self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle"));
|
||||
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = self.copies.get(found_path).cloned() {
|
||||
if by_name {
|
||||
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
|
||||
self.renamed(&mut lines, source, &shown, &file.name, &existing.name);
|
||||
}
|
||||
// Another document may place other pages of a PDF copied already.
|
||||
if self.opts.copy_fonts
|
||||
@@ -265,8 +273,8 @@ impl<'a> Collector<'a> {
|
||||
continue;
|
||||
}
|
||||
let Some(mut bytes) = file.bytes else {
|
||||
lines.push(format!("{}: not found ({}), not copied", file.name, file.path));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) });
|
||||
lines.push(format!("{}: not found ({shown}), not copied", file.name));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) });
|
||||
continue;
|
||||
};
|
||||
if self.copies.len() >= 10_000 {
|
||||
@@ -277,7 +285,7 @@ impl<'a> Collector<'a> {
|
||||
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
|
||||
let destination_file = format!("{dir}{unique}");
|
||||
if by_name {
|
||||
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
|
||||
self.renamed(&mut lines, source, &shown, &file.name, &destination_file);
|
||||
}
|
||||
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
|
||||
// read from its editing data links are copied beside it.
|
||||
@@ -285,7 +293,7 @@ impl<'a> Collector<'a> {
|
||||
let placed_file = pages.is_some();
|
||||
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
|
||||
} else {
|
||||
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
|
||||
}
|
||||
@@ -296,7 +304,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
if placed_file && super::place::document::is_native(&file.name, &bytes) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
|
||||
} else {
|
||||
match vectorcraft_format::load_file(&bytes) {
|
||||
Ok(saved) => {
|
||||
@@ -320,8 +328,7 @@ impl<'a> Collector<'a> {
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!(
|
||||
"{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
|
||||
file.path
|
||||
"{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked"
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -329,7 +336,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path),
|
||||
format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -337,7 +344,7 @@ impl<'a> Collector<'a> {
|
||||
let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) };
|
||||
self.copies.insert(found_path.to_string(), copy.clone());
|
||||
local.insert(file.path.clone(), copy);
|
||||
lines.push(format!("{} → {destination_file}", file.path));
|
||||
lines.push(format!("{shown} → {destination_file}"));
|
||||
self.entries.push((destination_file, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
@@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res
|
||||
// The report.
|
||||
if let Some(info) = info {
|
||||
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path));
|
||||
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
|
||||
text.push_str(&info);
|
||||
entries.push((report_name, text.into_bytes()));
|
||||
|
||||
@@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() {
|
||||
assert!(report.contains("blue.png: not found"), "{report}");
|
||||
}
|
||||
|
||||
/// Packaged for a client whose read root holds neither the document's file nor its links: the report,
|
||||
/// its Document Info part included, names them by their file names, and `document.info` gives no
|
||||
/// link. The person's report names them whole.
|
||||
#[test]
|
||||
fn a_confined_package_names_files_outside_the_read_root_by_name() {
|
||||
let dir = Folder::new("package-roots");
|
||||
let mut s = poster(&dir);
|
||||
std::fs::create_dir_all(dir.file("in")).unwrap();
|
||||
std::fs::remove_file(dir.file("art/blue.png")).unwrap();
|
||||
open(&mut s, &dir.file("poster.vectorcraft"));
|
||||
let report = |r: &Value| {
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap()
|
||||
};
|
||||
let person = report(&run(&mut s, "file.package", json!({})));
|
||||
assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}");
|
||||
assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}");
|
||||
assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}");
|
||||
|
||||
let in_root = dir.0.join("in");
|
||||
let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
|
||||
let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
|
||||
let text = report(&client);
|
||||
assert!(text.contains("From: poster.vectorcraft\n"), "{text}");
|
||||
assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}");
|
||||
let base = dir.0.to_string_lossy().into_owned();
|
||||
assert!(!text.contains(&base), "{text}");
|
||||
for key in ["missingLinks", "warnings", "files"] {
|
||||
assert!(!client[key].to_string().contains(&base), "{key}: {client}");
|
||||
}
|
||||
// Document Info, whose report the package holds, gives no link either.
|
||||
for p in [json!({}), json!({"format": "text"})] {
|
||||
let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone()));
|
||||
assert!(!info.to_string().contains(&base), "{p}: {info}");
|
||||
}
|
||||
let info = run(&mut s, "document.info", json!({"format": "text"}));
|
||||
assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}");
|
||||
}
|
||||
|
||||
/// A missing link stored as a path relative to the document names a file inside a confined
|
||||
/// request's read root: the package report and Document Info give its stored path.
|
||||
#[test]
|
||||
fn a_relative_link_inside_the_read_root_keeps_its_path() {
|
||||
let dir = Folder::new("package-roots-relative");
|
||||
write(&dir.file("art/near.png"), &png(8, 8, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &dir.file("art/near.png"));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png"));
|
||||
write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false));
|
||||
// Missing, the link keeps its stored path when the document opens (the package copies the
|
||||
// image the document holds).
|
||||
std::fs::remove_file(dir.file("art/near.png")).unwrap();
|
||||
open(&mut s, &dir.file("near.vectorcraft"));
|
||||
let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap();
|
||||
let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
|
||||
assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}");
|
||||
let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({})));
|
||||
let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect();
|
||||
assert_eq!(links, [&json!("art/near.png")], "{info}");
|
||||
let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"})));
|
||||
assert!(text.to_string().contains("art/near.png"), "{text}");
|
||||
}
|
||||
|
||||
/// A link whose stored path names a place outside a confined request's read root, with its file
|
||||
/// found by name beside the document inside the root: the package report and reply give the file
|
||||
/// name, not the stored path.
|
||||
#[test]
|
||||
fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() {
|
||||
let dir = Folder::new("package-roots-stale");
|
||||
write(&dir.file("in/near.png"), &png(8, 8, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &dir.file("in/near.png"));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
|
||||
let stale = dir.file("out/near.png");
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new(&stale));
|
||||
write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false));
|
||||
// Missing when the document opens, the link keeps its stored path; the file then appears beside it.
|
||||
std::fs::remove_file(dir.file("in/near.png")).unwrap();
|
||||
open(&mut s, &dir.file("in/near.vectorcraft"));
|
||||
write(&dir.file("in/near.png"), &png(8, 8, RED));
|
||||
let in_root = dir.0.join("in");
|
||||
let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
|
||||
let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
|
||||
assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}");
|
||||
assert!(!text.contains(&stale), "{text}");
|
||||
for key in ["missingLinks", "warnings", "files"] {
|
||||
assert!(!r[key].to_string().contains(&stale), "{key}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file missing from a document that moved keeps the place its relative path names from the
|
||||
/// document's folder: the packaged document writes its relative path from there.
|
||||
#[test]
|
||||
|
||||
@@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.node_count()
|
||||
}
|
||||
|
||||
/// A client confined by automation roots: a save without a path writes the document's own file, and
|
||||
/// when that file is outside the read roots the reply leaves out its `path` and names the files
|
||||
/// written beside it by their file names. A reply without a path leaves out a suggested `folder`
|
||||
/// outside them. A path inside the read roots, and one the client gives, stay whole.
|
||||
#[test]
|
||||
fn a_confined_save_leaves_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let d = dir("roots");
|
||||
let (inside, outside) = (d.join("in"), d.join("out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let own = path(&outside, "doc.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap();
|
||||
let files: Vec<String> = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}");
|
||||
|
||||
let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap();
|
||||
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, id: &str, p: Value| {
|
||||
confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
};
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
let r = run(&mut s, &write_only, "document.save", json!({}));
|
||||
assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}");
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true}));
|
||||
let names: Vec<Value> = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect();
|
||||
assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}");
|
||||
// Inside the read roots, or given by the client: whole.
|
||||
assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own));
|
||||
assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own));
|
||||
// A save without a path known suggests the document's folder.
|
||||
let r = run(&mut s, &write_only, "file.saveCopy", json!({}));
|
||||
assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}");
|
||||
assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_path_writes_svg_and_save_writes_it_back() {
|
||||
let d = dir("svg");
|
||||
|
||||
@@ -155,10 +155,20 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
"file.openWith" => io::open_with(app, 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),
|
||||
// A confined request sees the document's own file, and the folder a save suggests, only
|
||||
// inside its read roots.
|
||||
"file.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => {
|
||||
let mode = match id {
|
||||
"file.save" => SaveMode::Save,
|
||||
"file.saveAs" => SaveMode::SaveAs,
|
||||
"file.saveCopy" => SaveMode::Copy,
|
||||
_ => SaveMode::Template,
|
||||
};
|
||||
io::save(app, mode, p, true).map(|mut r| {
|
||||
vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str));
|
||||
r
|
||||
})
|
||||
}
|
||||
"document.exportSelection" => {
|
||||
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
|
||||
}
|
||||
|
||||
@@ -107,6 +107,27 @@ fn control_saves_take_the_native_save_options() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A save without a path writes the document's own file in the write root: `app.save` and
|
||||
/// `file.save` leave its path out of the reply, since it is outside the read root, and keep a path
|
||||
/// the client gives.
|
||||
#[test]
|
||||
fn control_saves_leave_out_the_documents_file_outside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("save-own");
|
||||
app.run("file.new", json!({"width": 100, "height": 50})).unwrap();
|
||||
let own = s(base.join("out/doc.vectorcraft"));
|
||||
app.run("file.saveAs", json!({"path": own})).unwrap();
|
||||
for (method, params) in [("app.save", json!({})), ("engine.execute", json!({"command": "file.save", "params": {}}))] {
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let r = request(&mut app, &tx, method, params);
|
||||
assert_eq!(r["ok"], true, "{method}: {r}");
|
||||
assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{method}: {r}");
|
||||
assert_eq!(app.session.active().map(|d| d.is_dirty()), Some(false), "{method}: saved");
|
||||
}
|
||||
let r = request(&mut app, &tx, "app.save", json!({"path": own}));
|
||||
assert_eq!(r["result"]["path"], json!(own), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true`
|
||||
/// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it
|
||||
/// before they reply. Without `wait` the refusal reaches only the status bar.
|
||||
|
||||
+12
@@ -123,6 +123,18 @@ the suite) keep the agent to the folders you give it:
|
||||
- Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and
|
||||
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
|
||||
library and preset saves to a path, the copies `links.relink {copy}` makes.
|
||||
- A path the agent gives stays in a save or export reply, with the files written beside it. Paths it did not
|
||||
give are left out when they are outside the read roots. `save_file`, `document.save` and `file.save` without
|
||||
a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files
|
||||
written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without
|
||||
one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder`
|
||||
of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error
|
||||
for that export names the folder by its name alone.
|
||||
`document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as
|
||||
a file) leave out a kept `folder` outside the read roots. `file.package`
|
||||
names the document's file and the linked files outside the read roots by their file names in `warnings`, in
|
||||
`missingLinks` and in its report. `document.info` gives `link: null` for an image linked to a file outside them,
|
||||
and its Linked Images rows and text report name that file alone. `action.batch` returns the replies of its steps as these commands give them.
|
||||
- `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163).
|
||||
- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The
|
||||
other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and
|
||||
|
||||
@@ -760,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
|
||||
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
|
||||
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
|
||||
- Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`.
|
||||
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
|
||||
- Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`).
|
||||
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
|
||||
|
||||
Reference in New Issue
Block a user