mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge pull request #7653 from dotCooCoo/fix/svg-export-confined-image-links
MCP: SVG output under read roots refuses to name a linked image file outside them
This commit is contained in:
@@ -32,7 +32,7 @@ use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg),
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg),
|
||||
cmd!(
|
||||
query "clipboard.importSvg",
|
||||
"Load SVG into Clipboard",
|
||||
@@ -435,14 +435,37 @@ impl Clipboard {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name
|
||||
/// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
if self.clipboard_svg_refused() {
|
||||
return None;
|
||||
}
|
||||
self.clipboard.svg()
|
||||
}
|
||||
|
||||
/// Would the internal clipboard's SVG name a linked image's file outside the read roots in
|
||||
/// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative
|
||||
/// path is followed from the document the objects came from while it is open; otherwise it
|
||||
/// counts as outside.
|
||||
pub fn clipboard_svg_refused(&self) -> bool {
|
||||
self.check_clipboard_svg("clipboard.exportSvg").is_err()
|
||||
}
|
||||
|
||||
/// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder.
|
||||
fn check_clipboard_svg(&self, cmd: &str) -> Result<()> {
|
||||
if crate::file_access::current().is_none() || !self.clipboard.has_art() {
|
||||
return Ok(());
|
||||
}
|
||||
let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc);
|
||||
let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of);
|
||||
super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "svg": s.clipboard_svg() }))
|
||||
s.check_clipboard_svg("clipboard.exportSvg")?;
|
||||
Ok(json!({ "svg": s.clipboard.svg() }))
|
||||
}
|
||||
|
||||
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -125,9 +125,10 @@ mod commands;
|
||||
mod export_names;
|
||||
mod formats;
|
||||
|
||||
pub(crate) use access::check_images_in;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::list_dir;
|
||||
pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
|
||||
pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_readonly;
|
||||
|
||||
@@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The error for an SVG that would name a linked image's file outside the read roots.
|
||||
pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots";
|
||||
|
||||
/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it
|
||||
/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and
|
||||
/// those too unless read roots are in force and the file would name a linked image's file outside
|
||||
/// them (a relative path taken from `st`'s folder; a URL is not checked). An `<image>` names the
|
||||
/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for
|
||||
/// the image, for the images of the art each file draws (with chosen artboards, the art over each
|
||||
/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask
|
||||
/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with
|
||||
/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them
|
||||
/// and each data set's image. The error names no file or folder.
|
||||
pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> {
|
||||
check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p)
|
||||
}
|
||||
|
||||
/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the
|
||||
/// objects on the clipboard, looked for from the document they came from).
|
||||
pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> {
|
||||
if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden
|
||||
// layers kept: the encoder refuses them anyway.
|
||||
let (link, preserve, hidden) = match super::svg::options_map(p) {
|
||||
Ok(m) => {
|
||||
let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false));
|
||||
(m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers"))
|
||||
}
|
||||
Err(_) => (true, true, true),
|
||||
};
|
||||
// The opacity-mask and symbol editing layers are never written.
|
||||
let d = &*d.without_edit_modes();
|
||||
let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden);
|
||||
if preserve && crate::cmd::links::names_hidden_file(d, base)
|
||||
|| names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f)))
|
||||
{
|
||||
return Err(bad(cmd, IMAGE_OUTSIDE));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ use crate::DocState;
|
||||
use super::super::{Result, Session, bad, check_switches, edit, number_or, switch_or, text_param};
|
||||
use super::encode::single_artboard;
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, Format, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
|
||||
write_or_return,
|
||||
ARTBOARD_PARAMS, Format, check_images_in, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output,
|
||||
writable, write_encoded, write_or_return,
|
||||
};
|
||||
|
||||
/// The keys the export commands take for every format, beside the path, the format and the
|
||||
@@ -287,6 +287,8 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let doc = export_doc(st, f, p)?;
|
||||
// The objects written (all, or with selectedOnly the selected ones) are checked.
|
||||
check_images_in(cmd, f.id, &doc, crate::cmd::links::LinkBase::of(st), p)?;
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
|
||||
}
|
||||
@@ -372,6 +374,8 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let (d, bounds) = selection(s, C, f.id == "txt")?;
|
||||
// The selected objects' links are looked for from the document they are in.
|
||||
check_images_in(C, f.id, &d, crate::cmd::links::LinkBase::of(s.doc()?), &without_artboards(&p))?;
|
||||
// The encoder's warnings (options accepted but not applied, features approximated or left
|
||||
// out), as document.export reports them.
|
||||
let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
|
||||
|
||||
@@ -891,6 +891,13 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
let cmd = plan.mode.command();
|
||||
super::check_linked_scripts(cmd, plan.format.id, st)?;
|
||||
let mut own = doc_to_save(st, plan.format);
|
||||
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);
|
||||
super::check_linked_images(cmd, plan.format.id, st, &own, ¶ms)?;
|
||||
// The file the document is saved as (else its own) titles it if New's name is all it has.
|
||||
let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref());
|
||||
if let Some(d) = super::titled_for_export(&own, file, plan.format.id) {
|
||||
@@ -905,12 +912,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
let base = crate::cmd::links::LinkBase::of(st);
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, base, p));
|
||||
let doc = relative.map_or(own, Arc::new);
|
||||
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 mut params = Value::Object(params);
|
||||
let mut params = params;
|
||||
if matches!(plan.format.id, "vectorcraft" | "template") {
|
||||
// Not remembered with the options: the preference decides each time it isn't given.
|
||||
params = with_compression_pref(&s.prefs, ¶ms);
|
||||
@@ -1307,7 +1309,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 name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), 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; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. 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. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
"{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 name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), 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; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked), also by file.saveAs and file.saveCopy. 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. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -1125,6 +1125,13 @@ pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value>
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
|
||||
// The file each item writes for each SVG row, before the folder is made or any row is written.
|
||||
let base = crate::cmd::links::LinkBase::of(s.doc()?);
|
||||
for r in rows.iter().filter(|r| matches!(r.format.id, "svg" | "svgz")) {
|
||||
for item in &items {
|
||||
super::check_images_in(C, r.format.id, &item.doc, base, &r.options_for(item.region, item.artboard)?)?;
|
||||
}
|
||||
}
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
// Include Artboard Fill off: the encoders leave the background colors out.
|
||||
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! params' top level or from an `svg` object (which wins), and the files an SVG export writes
|
||||
//! (one per artboard, plus the images it links to).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use vectorcraft_doc::Document;
|
||||
@@ -155,6 +157,18 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>,
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// The art the SVG files of `doc` with options `p` draw, as [`encode`] picks it: with chosen
|
||||
/// artboards, the art over each one; else all of `doc`. `None` when the options don't read.
|
||||
pub(super) fn drawn<'a>(doc: &'a Document, p: &Value) -> Option<Vec<Cow<'a, Document>>> {
|
||||
let (_, boards, chosen) = plan(doc, p).ok()?;
|
||||
if !chosen {
|
||||
return Some(vec![Cow::Borrowed(doc)]);
|
||||
}
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let rects: Vec<_> = boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect();
|
||||
Some(rects.into_iter().map(|r| Cow::Owned(super::export::art_over(doc, r, &mut renderer))).collect())
|
||||
}
|
||||
|
||||
/// What a save of `doc` as SVG (format `label`) with options `p` loses of several artboards: the
|
||||
/// file is one artboard and reopens as one, unless it carries the native document (Preserve
|
||||
/// Editing Capabilities).
|
||||
|
||||
@@ -1298,7 +1298,112 @@ pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
/// alone in a reply to the caller asking now? Only a path [`path_shown`] does not allow; a URL is
|
||||
/// always shown as it is.
|
||||
pub(crate) fn script_hidden(f: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url))
|
||||
file_hidden(f, doc_path)
|
||||
}
|
||||
|
||||
/// Is the file `path` (a link of the document saved at `doc_path`) one the caller asking now may
|
||||
/// not be shown? Only a path [`path_shown`] does not allow; a URL is not a file and is shown as it is.
|
||||
pub(crate) fn file_hidden(path: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(path, doc_path) && !matches!(fileio::document_path(path, doc_path), Err(craft_docpath::Refused::Url))
|
||||
}
|
||||
|
||||
/// Does an SVG of `d` (a document whose links are looked for from `base`) written for the caller
|
||||
/// asking now name the file of a linked image that [`file_hidden`] keeps from it? An `<image>`
|
||||
/// names its image's file with SVG Options › Images set to Link (`link`), and with Embed when the
|
||||
/// document holds no pixels for the image. An image with a solid colorize paint whose pixels are
|
||||
/// gray or missing names none: the SVG writes gray pixels colorized and leaves out an image
|
||||
/// without pixels. The images of the art the SVG writes (opacity masks included) and of the
|
||||
/// symbols, patterns and brushes that art uses, as Copy follows them, are checked: the SVG writes
|
||||
/// no other resource. Hidden layers and objects are written only with
|
||||
/// SVG Options › hidden layers kept (`hidden`), and template layers never ([`svg_written`]).
|
||||
/// Always `false` without read roots in force.
|
||||
pub(crate) fn svg_names_hidden_image(d: &Document, base: LinkBase, link: bool, hidden: bool) -> bool {
|
||||
if crate::file_access::current().is_none() || !names_hidden_image(d, base, link) {
|
||||
return false;
|
||||
}
|
||||
let art = d.layers.iter().filter_map(|l| svg_written(l, hidden)).collect();
|
||||
names_hidden_image(&super::clipboard::Clipboard::gather(d, art).to_document(), base, link)
|
||||
}
|
||||
|
||||
/// `n` as the SVG writer writes it, or `None` when it writes none of it: a hidden layer or object
|
||||
/// is left out unless `hidden`, a template layer's art always, and a disabled opacity mask's art.
|
||||
/// Only layers and groups lose members; other objects keep theirs as they are.
|
||||
fn svg_written(n: &Node, hidden: bool) -> Option<Node> {
|
||||
if !(n.visible || hidden) {
|
||||
return None;
|
||||
}
|
||||
let mut n = n.clone();
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled).and_then(|m| {
|
||||
let art = svg_written(&m.art, hidden)?;
|
||||
Some(Box::new(vectorcraft_doc::OpacityMask { art: std::sync::Arc::new(art), ..m.clone() }))
|
||||
});
|
||||
if n.extras().mask.is_some() {
|
||||
n.extras_mut().mask = mask;
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Layer { template: true, children, .. } => children.clear(),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
*children = children.iter().filter_map(|c| svg_written(c, hidden)).map(std::sync::Arc::new).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// [`svg_names_hidden_image`] for every image of `d`: in the layers, symbols, graph designs,
|
||||
/// patterns, opacity masks and the art of the stored brushes, used or not.
|
||||
fn names_hidden_image(d: &Document, base: LinkBase, link: bool) -> bool {
|
||||
let d = with_brush_art(d);
|
||||
let mut hidden = false;
|
||||
d.visit_images(|n, im| {
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
let shown = d.shown_image(n, im);
|
||||
let Some(l) = &shown.link else { return };
|
||||
let blob = d.image_blob(&shown).filter(|b| !b.bytes.is_empty());
|
||||
// The SVG writes an image with a solid colorize paint as colorized pixels when they are
|
||||
// gray, and leaves it out when the document holds none of them.
|
||||
if matches!(n.extras().colorize.as_deref(), Some(vectorcraft_color::Paint::Solid { .. })) && blob.is_none_or(|b| b.gray_source().is_some()) {
|
||||
return;
|
||||
}
|
||||
let named = link || blob.is_none();
|
||||
hidden = named && file_hidden(&l.path, base.doc);
|
||||
});
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Does the native document an SVG of `d` (a document whose links are looked for from `base`)
|
||||
/// carries with Preserve Editing Capabilities name a file that the caller asking now may not be
|
||||
/// shown: the file of a linked image or placed document ([`file_hidden`]), the relative path kept
|
||||
/// of one, which is followed from the file the document was read from and from the document's own
|
||||
/// folder, or a data set's image? Links in the layers, symbols, graph designs, patterns, the pattern
|
||||
/// being edited as it was before, opacity masks and the art of the stored brushes are all checked,
|
||||
/// as [`with_hidden_paths_named`] checks them. A stored brush library that is not a list, or holds
|
||||
/// an entry that does not read as a brush, counts as naming one: the native document carries it as
|
||||
/// it is, and its links cannot be checked. Always `false` without read roots in force.
|
||||
pub(crate) fn names_hidden_file(d: &Document, base: LinkBase) -> bool {
|
||||
if crate::file_access::current().is_none() {
|
||||
return false;
|
||||
}
|
||||
let unread = |list: &Vec<Value>| list.iter().any(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).is_err());
|
||||
if d.unknown.get(vectorcraft_brush::DOC_KEY).is_some_and(|v| v.as_array().is_none_or(unread)) {
|
||||
return true;
|
||||
}
|
||||
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
|
||||
let hidden = |l: &LinkInfo| file_hidden(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden);
|
||||
let image_hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if file_hidden(path, base.doc));
|
||||
if d.variables.datasets.iter().any(|set| set.values.values().any(image_hidden)) {
|
||||
return true;
|
||||
}
|
||||
let mut d = with_brush_art(d);
|
||||
if let Some(original) = d.pattern_edit.as_ref().and_then(|e| e.original.clone()) {
|
||||
d.to_mut().patterns.push(original);
|
||||
}
|
||||
let mut found = false;
|
||||
d.visit_images(|_, im| found |= im.link.as_ref().is_some_and(hidden));
|
||||
d.visit_placed(|_, p| found |= hidden(&p.link));
|
||||
found
|
||||
}
|
||||
|
||||
/// The linked JavaScript file `f` of the open document `st` as a reply to the caller asking now
|
||||
|
||||
@@ -1791,3 +1791,350 @@ fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() {
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png"));
|
||||
}
|
||||
|
||||
/// Image `id` of the active document without pixels in the document, as a linked file that could
|
||||
/// not be read leaves it.
|
||||
fn without_pixels(s: &mut Session, id: NodeId) {
|
||||
let key = image(s, id).key;
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.remove(&key);
|
||||
}
|
||||
|
||||
/// The text of `document.serialize`'s reply `r` (an SVG, or the SVGZ it holds).
|
||||
fn svg_text(r: &Value) -> String {
|
||||
match r["text"].as_str() {
|
||||
Some(text) => text.to_string(),
|
||||
None => {
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
vectorcraft_svg::text_of(&bytes).unwrap().into_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caller confined to read roots: every SVG and SVGZ output that would name a linked image's file
|
||||
/// outside them (Images: Link, Preserve Editing Capabilities, or Embed for an image without pixels
|
||||
/// in the document) is refused before a file or folder is written, with an error that names no
|
||||
/// file or folder, and the document keeps the link. Embed with the pixels names no file.
|
||||
#[test]
|
||||
fn confined_svg_output_refuses_a_linked_image_file_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-away");
|
||||
let home = Folder::new("svg-image-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("assets.add", &json!({"ids": [a.0]})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let hidden = [away.0.to_string_lossy().into_owned(), home.0.to_string_lossy().into_owned(), "photo.png".to_string()];
|
||||
let refused = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute(cmd, &p)).expect_err(cmd).to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{cmd} {p}: {e}");
|
||||
for h in &hidden {
|
||||
assert!(!e.contains(h.as_str()), "{cmd} {p} names {h}: {e}");
|
||||
}
|
||||
};
|
||||
let out = home.file("out");
|
||||
// Every SVG output, with the options `o` given flat.
|
||||
let every = |s: &mut Session, o: &Value| {
|
||||
for format in ["svg", "svgz"] {
|
||||
let file = |name: &str| home.file(&format!("{name}.{format}"));
|
||||
let with = |p: Value| {
|
||||
let mut p = p;
|
||||
if let (Some(m), Some(o)) = (p.as_object_mut(), o.as_object()) {
|
||||
m.extend(o.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
refused(s, "document.serialize", with(json!({"format": format})));
|
||||
refused(s, "document.export", with(json!({"format": format})));
|
||||
refused(s, "document.export", with(json!({"format": format, "path": file("export")})));
|
||||
refused(s, "document.export", with(json!({"format": format, "selectedOnly": true})));
|
||||
refused(s, "document.exportSelection", with(json!({"format": format, "path": file("selection")})));
|
||||
refused(s, "document.save", with(json!({"path": file("save")})));
|
||||
refused(s, "file.saveAs", with(json!({"format": format, "path": file("save-as")})));
|
||||
refused(s, "file.saveCopy", with(json!({"format": format, "path": file("copy")})));
|
||||
let row = with(json!({"format": format}));
|
||||
refused(s, "document.exportForScreens", json!({"folder": out, "formats": [{"format": "png"}, row]}));
|
||||
refused(s, "document.exportForScreens", json!({"zip": true, "formats": [row]}));
|
||||
refused(s, "assets.export", json!({"folder": out, "formats": [row]}));
|
||||
}
|
||||
};
|
||||
every(&mut s, &json!({"images": "link"}));
|
||||
every(&mut s, &json!({"preserveEditing": true}));
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "svg": {"images": "link"}}));
|
||||
// Embedded pixels name no file.
|
||||
for format in ["svg", "svgz"] {
|
||||
let r = confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": format}))).unwrap();
|
||||
let text = svg_text(&r);
|
||||
assert!(text.contains("data:image/png;base64,") && !text.contains(&hidden[0]), "{format}: {text}");
|
||||
}
|
||||
// Without its pixels, an embedded image names its file.
|
||||
without_pixels(&mut s, a);
|
||||
let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
assert!(svg_text(&r).contains(&format!("xlink:href=\"{photo}\"")), "{r}");
|
||||
every(&mut s, &json!({}));
|
||||
let written: Vec<_> = std::fs::read_dir(&home.0).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
assert_eq!(image(&s, a).link.map(|l| l.path), Some(photo));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG leaves out hidden layers and objects, unless hidden layers are kept (as
|
||||
/// a save keeps them by default), and template layers always: a linked image file outside the read
|
||||
/// roots in art the file leaves out does not refuse it, and in art the file writes does.
|
||||
#[test]
|
||||
fn confined_svg_output_checks_only_the_linked_images_it_writes() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-unwritten-away");
|
||||
let home = Folder::new("svg-image-unwritten-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
// A visible rectangle, and the linked image in a layer of its own.
|
||||
let setup = || {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let layer = NodeId(s.execute("layer.new", &json!({"top": true})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("layer.setCurrent", &json!({"id": layer.0})).unwrap();
|
||||
let a = place(&mut s, &photo);
|
||||
assert!(s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().any(|c| c.id == a), "the image is in the new layer");
|
||||
(s, layer, a)
|
||||
};
|
||||
let written = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
if cmd == "document.serialize" {
|
||||
let text = svg_text(&r);
|
||||
assert!(!text.contains(&photo) && !text.contains("photo.png"), "{cmd} {p}: {text}");
|
||||
}
|
||||
};
|
||||
let refused = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute(cmd, &p)).expect_err(cmd).to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{cmd} {p}: {e}");
|
||||
};
|
||||
// Exports leave hidden art out; hidden layers kept, and a save, write it.
|
||||
let hidden_art = |s: &mut Session, name: &str| {
|
||||
for format in ["svg", "svgz"] {
|
||||
written(s, "document.serialize", json!({"format": format, "images": "link"}));
|
||||
written(s, "document.export", json!({"format": format, "images": "link", "path": home.file(&format!("{name}.{format}"))}));
|
||||
written(s, "document.export", json!({"format": format, "images": "link", "svg": {"hiddenLayers": false}}));
|
||||
refused(s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
refused(s, "document.export", json!({"format": format, "svg": {"images": "link", "hiddenLayers": true}}));
|
||||
refused(s, "document.save", json!({"path": home.file(&format!("{name}-save.{format}")), "images": "link"}));
|
||||
refused(s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("{name}-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
// A save told to leave hidden layers out leaves the image out too.
|
||||
let copy = home.file(&format!("{name}-copy.svg"));
|
||||
written(s, "file.saveCopy", json!({"format": "svg", "path": copy, "images": "link", "hiddenLayers": false}));
|
||||
};
|
||||
let (mut s, layer, _) = setup();
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "visible": false})).unwrap();
|
||||
hidden_art(&mut s, "hidden-layer");
|
||||
let (mut s, _, a) = setup();
|
||||
s.execute("object.hide", &json!({"ids": [a.0]})).unwrap();
|
||||
hidden_art(&mut s, "hidden-object");
|
||||
// A template layer is never written, not even with hidden layers kept.
|
||||
let (mut s, layer, _) = setup();
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "template": true})).unwrap();
|
||||
for format in ["svg", "svgz"] {
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link"}));
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
written(&mut s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("template-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
// Back to an ordinary layer, the image is written and refuses the file.
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "template": false})).unwrap();
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "images": "link"}));
|
||||
// The layer of symbol editing mode is never written: editing a symbol no art uses leaves its
|
||||
// image out of the file.
|
||||
let (mut s, _, a) = setup();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let instance = s.execute("symbol.new", &json!({"name": "Away"})).unwrap()["id"].clone();
|
||||
s.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
|
||||
s.execute("symbol.edit", &json!({"name": "Away"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_some(), "in symbol editing mode");
|
||||
for format in ["svg", "svgz"] {
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
written(&mut s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("symbol-edit-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
s.execute("symbol.exitEditing", &json!({})).unwrap();
|
||||
s.execute("symbol.place", &json!({"name": "Away"})).unwrap();
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "images": "link"}));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG of a grayscale linked image outside the read roots that a solid fill
|
||||
/// colorizes: the file gets the colorized pixels, or leaves the image out without pixels, and names
|
||||
/// no file with Link or Embed. With Preserve Editing Capabilities the document the SVG carries
|
||||
/// keeps the link, and the SVG is refused.
|
||||
#[test]
|
||||
fn confined_svg_output_writes_a_colorized_gray_image_without_its_file() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-colorized-away");
|
||||
let home = Folder::new("svg-image-colorized-home");
|
||||
let photo = away.file("gray.png");
|
||||
let mut bytes = vec![];
|
||||
image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap().write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
write(&photo, &bytes);
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
|
||||
let colorized = s.doc().unwrap().doc.node(a).unwrap().extras().colorize.clone();
|
||||
assert!(matches!(colorized.as_deref(), Some(vectorcraft_color::Paint::Solid { .. })), "{colorized:?}");
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let written = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
if cmd == "document.serialize" {
|
||||
let text = svg_text(&r);
|
||||
assert!(!text.contains(&away.0.to_string_lossy().into_owned()) && !text.contains("href=\"gray.png"), "{cmd} {p}: {text}");
|
||||
}
|
||||
};
|
||||
let refused = |s: &mut Session, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute("document.serialize", &p)).expect_err("preserveEditing").to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{p}: {e}");
|
||||
};
|
||||
let every = |s: &mut Session, name: &str| {
|
||||
for format in ["svg", "svgz"] {
|
||||
for images in ["link", "embed"] {
|
||||
written(s, "document.serialize", json!({"format": format, "images": images}));
|
||||
written(s, "document.export", json!({"format": format, "images": images, "path": home.file(&format!("{name}-{images}.{format}"))}));
|
||||
let copy = home.file(&format!("{name}-{images}-copy.{format}"));
|
||||
written(s, "file.saveCopy", json!({"format": format, "images": images, "path": copy}));
|
||||
}
|
||||
refused(s, json!({"format": format, "preserveEditing": true}));
|
||||
}
|
||||
};
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": "svg"}))).unwrap());
|
||||
assert!(text.contains("data:image/png;base64,"), "{text}");
|
||||
every(&mut s, "pixels");
|
||||
without_pixels(&mut s, a);
|
||||
every(&mut s, "missing");
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let svg = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap()["svg"].as_str().unwrap().to_string();
|
||||
assert!(!svg.contains(&photo), "{svg}");
|
||||
assert_eq!(image(&s, a).link.map(|l| l.path), Some(photo));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG names each linked image file it may read, as stored: a file inside the
|
||||
/// read roots, a path relative to the document's folder that leads inside them, and a URL. The
|
||||
/// selected objects alone are exported when the image outside them is not selected, and a caller
|
||||
/// without roots gets a file outside them named too.
|
||||
#[test]
|
||||
fn svg_output_names_linked_image_files_a_caller_may_read() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-kept-away");
|
||||
let home = Folder::new("svg-image-kept-home");
|
||||
let inside = home.file("photo.png");
|
||||
let (nested, web) = (home.file("lib/photo.png"), home.file("web.png"));
|
||||
for f in [&inside, &nested, &web] {
|
||||
write(f, &png(60, 30, RED));
|
||||
}
|
||||
let url = "https://example.com/images/photo.png";
|
||||
let mut s = session();
|
||||
place(&mut s, &inside);
|
||||
place(&mut s, &nested);
|
||||
place(&mut s, &web);
|
||||
save(&mut s, &home.file("doc.vectorcraft"));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.update_links_where(|l| l.path == nested, |l| l.path = "lib/photo.png".into());
|
||||
doc.update_links_where(|l| l.path == web, |l| l.path = url.into());
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let link = json!({"format": "svg", "images": "link"});
|
||||
for format in ["svg", "svgz"] {
|
||||
let p = json!({"format": format, "images": "link"});
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap());
|
||||
for file in [inside.as_str(), "lib/photo.png", url] {
|
||||
assert!(text.contains(&format!("xlink:href=\"{file}\"")), "{format} {file}: {text}");
|
||||
}
|
||||
}
|
||||
// An image outside them in a symbol no art uses is not written.
|
||||
let unused = away.file("unused.png");
|
||||
write(&unused, &png(60, 30, BLUE));
|
||||
let u = place(&mut s, &unused);
|
||||
s.execute("select.set", &json!({"ids": [u.0]})).unwrap();
|
||||
let instance = s.execute("symbol.new", &json!({"name": "Away"})).unwrap()["id"].clone();
|
||||
s.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 1);
|
||||
for format in ["svg", "svgz"] {
|
||||
let p = json!({"format": format, "images": "link"});
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap());
|
||||
assert!(!text.contains(&unused), "{format}: {text}");
|
||||
}
|
||||
let outside = away.file("photo.png");
|
||||
write(&outside, &png(60, 30, BLUE));
|
||||
place(&mut s, &outside);
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r["id"].clone()]})).unwrap();
|
||||
let p = json!({"format": "svg", "images": "link"});
|
||||
let r = confine(roots.as_ref(), || s.execute("document.exportSelection", &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{r}");
|
||||
let p = json!({"format": "svg", "images": "link", "selectedOnly": true});
|
||||
for cmd in ["document.serialize", "document.export"] {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{cmd}: {r}");
|
||||
}
|
||||
// The file of an artboard the image is not over.
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||||
let p = json!({"format": "svg", "images": "link", "range": "2"});
|
||||
let r = confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{r}");
|
||||
let p = json!({"folder": home.file("screens"), "formats": [{"format": "svg", "images": "link"}], "range": "2"});
|
||||
confine(roots.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap();
|
||||
// Without roots, a file outside them is named as stored.
|
||||
let text = svg_text(&s.execute("document.serialize", &link).unwrap());
|
||||
assert!(text.contains(&format!("xlink:href=\"{outside}\"")), "{text}");
|
||||
}
|
||||
|
||||
/// The clipboard's SVG for a confined caller: embedded pixels name no file; an image whose pixels
|
||||
/// the clipboard doesn't hold makes `clipboard.exportSvg` refuse with an error that names no file
|
||||
/// or folder, and the system clipboard gets no SVG. A caller without roots gets the file named.
|
||||
#[test]
|
||||
fn confined_clipboard_svg_refuses_a_linked_image_file_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("clip-image-away");
|
||||
let home = Folder::new("clip-image-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let r = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap();
|
||||
let svg = r["svg"].as_str().unwrap();
|
||||
assert!(svg.contains("data:image/png;base64,") && !svg.contains(&photo), "{svg}");
|
||||
|
||||
without_pixels(&mut s, a);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let e = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap_err().to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{e}");
|
||||
assert!(!e.contains(&away.0.to_string_lossy().into_owned()) && !e.contains("photo.png"), "{e}");
|
||||
assert!(confine(roots.as_ref(), || s.clipboard_svg()).is_none());
|
||||
let svg = s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains(&format!("xlink:href=\"{photo}\"")), "{svg}");
|
||||
}
|
||||
|
||||
/// A confined caller's SVG with Preserve Editing Capabilities is refused while the stored brush
|
||||
/// library holds an entry that does not read as a brush, or is not a list: the native document
|
||||
/// carries it as it is, and its links cannot be checked. Without Preserve Editing the SVG is written.
|
||||
#[test]
|
||||
fn confined_preserve_editing_svg_refuses_a_brush_library_entry_it_cannot_read() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let home = Folder::new("svg-brush-entry-home");
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let preserve = json!({"format": "svg", "preserveEditing": true});
|
||||
confine(roots.as_ref(), || s.execute("document.serialize", &preserve)).unwrap();
|
||||
for stored in [json!(["not a brush"]), json!({"link": "/away/photo.png"})] {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.unknown.insert(vectorcraft_brush::DOC_KEY.into(), stored.clone());
|
||||
let e = confine(roots.as_ref(), || s.execute("document.serialize", &preserve)).unwrap_err().to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{stored}: {e}");
|
||||
confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": "svg"}))).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1366,7 +1366,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.exportAs",
|
||||
"Export As…",
|
||||
"",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them, as document.export refuses it",
|
||||
),
|
||||
(
|
||||
"ui.fileInfoDialog",
|
||||
|
||||
@@ -299,14 +299,17 @@ fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
|
||||
Some((_, c)) => c,
|
||||
None => {
|
||||
const MAX: usize = 200_000;
|
||||
let params = json!({ "svg": opts });
|
||||
let encoded = vectorcraft_engine::cmd::fileio::check_linked_scripts("document.export", "svg", st).and_then(|()| {
|
||||
let params = json!({ "svg": opts });
|
||||
if selection {
|
||||
let (doc, _) = vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false)?;
|
||||
vectorcraft_engine::cmd::fileio::encode_all(&doc, "svg", ¶ms)
|
||||
let selected;
|
||||
let doc = if selection {
|
||||
selected = vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false)?.0;
|
||||
&selected
|
||||
} else {
|
||||
vectorcraft_engine::cmd::fileio::encode_all(&st.doc, "svg", ¶ms)
|
||||
}
|
||||
&st.doc
|
||||
};
|
||||
vectorcraft_engine::cmd::fileio::check_linked_images("document.export", "svg", st, doc, ¶ms)?;
|
||||
vectorcraft_engine::cmd::fileio::encode_all(doc, "svg", ¶ms)
|
||||
});
|
||||
let mut text = match encoded {
|
||||
// Shown as text whatever the file's encoding.
|
||||
@@ -446,6 +449,52 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Show Code under read roots draws no SVG that would name a linked image's file outside them
|
||||
/// (Images: Link): it shows the error, which names no file or folder. Embedded pixels are
|
||||
/// shown, and without roots the link is shown.
|
||||
#[test]
|
||||
fn show_code_leaves_out_a_linked_image_file_outside_the_read_roots() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let root = tmp.join(format!("vc-svg-image-root-{}", std::process::id()));
|
||||
let away = tmp.join(format!("vc-svg-image-away-{}", std::process::id()));
|
||||
for dir in [&root, &away] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let photo = away.join("photo.png").to_string_lossy().into_owned();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
let (mut app, _) = app("/tmp/code.svg");
|
||||
app.run("file.place", json!({"path": photo, "link": true, "at": [50, 50]})).unwrap();
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("showCode".into(), json!(true));
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap().unwrap();
|
||||
// The text the code view shows after two frames in one context, with `roots` in force.
|
||||
let code = |app: &mut VectorcraftApp, roots: Option<&Arc<vectorcraft_engine::file_access::AutomationRoots>>| {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
for _ in 0..2 {
|
||||
let mut out =
|
||||
vectorcraft_engine::file_access::confine(roots, || ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.data(|m| m.get_temp::<(u64, Arc<String>)>(egui::Id::unique("svg-code"))).map(|(_, c)| c.to_string()).unwrap_or_default()
|
||||
};
|
||||
let embedded = code(&mut app, Some(&roots));
|
||||
assert!(embedded.contains("data:image/png;base64,") && !embedded.contains(&photo), "{embedded}");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("images".into(), json!("link"));
|
||||
let confined = code(&mut app, Some(&roots));
|
||||
assert!(confined.contains("a linked image file is outside the read roots"), "{confined}");
|
||||
assert!(!confined.contains("photo.png") && !confined.contains("vc-svg-image"), "{confined}");
|
||||
let shown = code(&mut app, None);
|
||||
assert!(shown.contains(&format!("xlink:href=\"{photo}\"")), "{shown}");
|
||||
for dir in [root, away] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show Code under Export Selection shows the SVG that OK writes: the selection alone, cropped
|
||||
/// to it with what its effects draw, not the whole document.
|
||||
#[test]
|
||||
|
||||
@@ -8998,6 +8998,7 @@ breakText Glyphs Glyphen
|
||||
@msg a line can't run from a point to itself eine Linie kann nicht von einem Punkt zu sich selbst verlaufen
|
||||
@msg a line needs at least two points eine Linie braucht mindestens zwei Punkte
|
||||
@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner
|
||||
@msg a linked image file is outside the read roots eine verknüpfte Bilddatei liegt außerhalb der Lesestammordner
|
||||
@msg a number cannot be read eine Zahl kann nicht gelesen werden
|
||||
@msg a swatch exchange block is too long ein Block der Farbfeld-Austauschdatei ist zu lang
|
||||
@msg a swatch exchange name is too long ein Name in der Farbfeld-Austauschdatei ist zu lang
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6029,6 +6029,7 @@ imageTrace Outline Contour
|
||||
@msg a layer's form has no dictionary het formulier van een laag heeft geen woordenboek
|
||||
@msg a legend draws at most {MAX_CHIPS} swatches een legenda tekent maximaal {MAX_CHIPS} stalen
|
||||
@msg a linked JavaScript file is outside the read roots een gekoppeld JavaScript-bestand staat buiten de leesroots
|
||||
@msg a linked image file is outside the read roots een gekoppeld afbeeldingsbestand staat buiten de leesroots
|
||||
@msg a name names one asset: give multiple: false, or one object een naam noemt één asset: geef multiple: false, of één object
|
||||
@msg a namespace declaration uses a reserved prefix or URI een naamruimtedeclaratie gebruikt een gereserveerd voorvoegsel of een gereserveerde URI
|
||||
@msg a namespace qualifier is undefined een naamruimtekwalificatie is niet gedefinieerd
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -82,6 +82,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
|
||||
}
|
||||
};
|
||||
fileio::check_linked_images("file.exportAs", f.id, st, &doc, params).map_err(|e| e.to_string())?;
|
||||
// A PDF shows its title in the viewer's title bar only when it is the document's own, not the
|
||||
// file name it was titled after, as `document.export` writes it.
|
||||
let own_title = fileio::has_own_title(&doc);
|
||||
|
||||
@@ -1367,7 +1367,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.exportAs",
|
||||
"Export As…",
|
||||
"",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them, as document.export refuses it",
|
||||
),
|
||||
(
|
||||
"ui.fileInfoDialog",
|
||||
|
||||
@@ -189,7 +189,10 @@ impl VectorcraftApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (clip, svg, pdf) = (self.session.clipboard.clone(), self.session.prefs.copy_as_svg, self.session.prefs.copy_as_pdf);
|
||||
// An SVG that would name a linked image's file outside the read roots of the automation
|
||||
// that asked for the Copy is left out (decided now: the formats are made later).
|
||||
let svg = self.session.prefs.copy_as_svg && !self.session.clipboard_svg_refused();
|
||||
let (clip, pdf) = (self.session.clipboard.clone(), self.session.prefs.copy_as_pdf);
|
||||
let latest = self.clipboard_publishing.latest.clone();
|
||||
let n = latest.fetch_add(1, Ordering::SeqCst).wrapping_add(1);
|
||||
self.clipboard_publishing.pending = true;
|
||||
|
||||
@@ -272,6 +272,46 @@ fn control_svg_export_refuses_a_linked_javascript_file_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `app.export`, and `file.exportAs` run as a command, write no SVG that would name a linked
|
||||
/// image's file outside the read root (Images: Link, Preserve Editing Capabilities), and the error
|
||||
/// names no file or folder. Embedded pixels are written; the person at the keyboard exports the
|
||||
/// link as stored.
|
||||
#[test]
|
||||
fn control_svg_export_refuses_a_linked_image_file_outside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("svg-image");
|
||||
app.run("file.new", json!({"width": 100, "height": 50})).unwrap();
|
||||
let photo = s(base.join("outside/photo.png"));
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
app.run("file.place", json!({"path": photo, "link": true, "at": [50, 25]})).unwrap();
|
||||
let path = s(base.join("out/a.svg"));
|
||||
for (method, params) in [
|
||||
("app.export", json!({"path": path, "format": "svg", "images": "link", "wait": true})),
|
||||
("app.export", json!({"path": s(base.join("out/a.svgz")), "images": "link", "wait": true})),
|
||||
("app.export", json!({"format": "svg", "images": "link"})),
|
||||
("app.export", json!({"format": "svg", "preserveEditing": true})),
|
||||
("engine.execute", json!({"command": "file.exportAs", "params": {"path": path, "format": "svg", "images": "link"}})),
|
||||
] {
|
||||
let r = request(&mut app, &tx, method, params.clone());
|
||||
let e = r["error"].as_str().unwrap_or_default();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{method} {params}: {r}");
|
||||
assert!(!e.contains("photo.png") && !e.contains(&s(base.clone())), "{method} {params}: {r}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 0, "nothing written");
|
||||
let r = request(&mut app, &tx, "app.export", json!({"path": path, "format": "svg", "wait": true}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let svg = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(svg.contains("data:image/png;base64,") && !svg.contains(&photo), "{svg}");
|
||||
// The person at the keyboard isn't confined.
|
||||
app.run("file.exportAs", json!({"path": path, "format": "svg", "images": "link"})).unwrap();
|
||||
let svg = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(svg.contains(&format!("xlink:href=\"{photo}\"")), "{svg}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `app.export` with a path writes the file when given an option only another format reads, and
|
||||
/// its warnings name that option, as `document.export`'s do without a path. `file.exportAs` run
|
||||
/// as a command does the same; an export that automation did not ask for gets no such note.
|
||||
|
||||
+15
@@ -246,6 +246,21 @@ the suite) keep the agent to the folders you give it:
|
||||
without a path of a document whose own format is SVG or SVGZ), `file.exportAs`, and the SVG Options dialog's
|
||||
Show Code. A file given as another URL (`https://…`) is not checked. The document keeps the link, and the
|
||||
person at the keyboard exports it as stored.
|
||||
- The same SVG and SVGZ outputs are refused with "a linked image file is outside the read roots", which names no
|
||||
file or folder, when the file would name a linked image's file outside the read roots (a path, a `file:` URL,
|
||||
or a relative path taken from the document's folder). An `<image>` names the file with `images: "link"`, and
|
||||
with `embed` when the document holds no pixels for the image. An image colorized with a solid fill names
|
||||
no file when its pixels are gray (the file gets the colorized pixels) or missing (the file leaves it out).
|
||||
With `preserveEditing`, the document the SVG carries names every linked image and placed file, the relative
|
||||
paths kept of them and each data set's image, in every symbol, pattern, graph design and brush, used or not,
|
||||
colorized or not, and a stored brush entry that does not read as a brush is refused too, since its links
|
||||
cannot be checked. Otherwise the images of the art the file writes,
|
||||
in opacity masks and in the symbols, patterns and brushes that art uses, are checked: hidden layers and
|
||||
objects only with `hiddenLayers` (a save keeps them unless it is `false`), and template layers and the
|
||||
layer of opacity-mask or symbol editing mode never. `selectedOnly` and `document.exportSelection` check the
|
||||
selected objects only, and `clipboard.exportSvg` checks the copied objects, following a relative path from
|
||||
the document they came from while it is open; in a frame a request drives, Copy then puts no SVG on the
|
||||
system clipboard. An image whose pixels the document holds exports embedded, and a URL is not checked.
|
||||
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
|
||||
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
|
||||
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
|
||||
|
||||
@@ -856,6 +856,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- 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`).
|
||||
- Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`).
|
||||
- SVG output under `--automation-read-root`: `document.serialize`, `document.export`, `document.exportSelection`, `document.exportForScreens`, `assets.export`, the SVG and SVGZ saves, `file.exportAs` and the SVG Options dialog's Show Code refuse an SVG or SVGZ of a document that links a JavaScript file outside the read roots, before any file or folder is written, with an error that names no file or folder; a URL, a file inside the roots and a relative path that leads inside them are written as stored, and the person at the keyboard exports every link as stored. `crates/engine/src/tests/interactivity.rs` (`confined_svg_output_refuses_a_linked_javascript_file_outside_the_read_roots`, `svg_output_keeps_linked_javascript_files_a_caller_may_read`), `crates/ui-egui/src/tests/automation.rs` (`control_svg_export_refuses_a_linked_javascript_file_outside_the_read_root`), `crates/ui-egui/src/dialogs/svg_options.rs` (`show_code_leaves_out_a_linked_javascript_file_outside_the_read_roots`).
|
||||
- Linked images in SVG output under `--automation-read-root`: the same outputs, and `clipboard.exportSvg`, refuse an SVG or SVGZ that would name a linked image's file outside the read roots (`images: link`, an image whose pixels the document doesn't hold, or `preserveEditing`), before any file or folder is written, with an error that names no file or folder; Copy in a frame a request drives then puts no SVG on the system clipboard. Embedded pixels, a file inside the roots, a relative path that leads inside them and a URL are written as before, and the person at the keyboard exports every link as stored. `crates/engine/src/tests/links.rs` (`confined_svg_output_refuses_a_linked_image_file_outside_the_read_roots`, `svg_output_names_linked_image_files_a_caller_may_read`, `confined_clipboard_svg_refuses_a_linked_image_file_outside_the_read_roots`, `confined_preserve_editing_svg_refuses_a_brush_library_entry_it_cannot_read`, `confined_svg_output_checks_only_the_linked_images_it_writes`, `confined_svg_output_writes_a_colorized_gray_image_without_its_file`), `crates/ui-egui/src/tests/automation.rs` (`control_svg_export_refuses_a_linked_image_file_outside_the_read_root`), `crates/ui-egui/src/dialogs/svg_options.rs` (`show_code_leaves_out_a_linked_image_file_outside_the_read_roots`).
|
||||
- Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`.
|
||||
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/<file name> (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
|
||||
- Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`.
|
||||
|
||||
Reference in New Issue
Block a user