diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..9903ba4cb 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,8 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no +//! built-in format reads). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -30,6 +32,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +mod importer; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; @@ -128,8 +131,8 @@ use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, - file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, read_extensions, - readable_format, readable_format_for_name, titled_for_export, writable_format, + file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, open_filters, place_filters, + plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index e11b6adcd..5e2178cdc 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -516,12 +516,12 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result bool { - use super::{FORMATS, OPEN_EXTS, extension}; + use super::{FORMATS, extension, open_extensions}; let ext = extension(path); if ext.is_empty() { return false; } - OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) + open_extensions().iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 011d025e3..ec7e303c6 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", always, formats ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..49f8af812 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -651,11 +651,12 @@ pub fn read_extensions(f: &Format) -> &'static [&'static str] { READ_ALIASES.iter().find(|(id, _)| *id == f.id).map_or(f.extensions, |(_, exts)| *exts) } -/// The readable format of extension `ext` (any case, leading dot allowed): [`format`]'s when it -/// reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). +/// The built-in readable format of extension `ext` (any case, leading dot allowed): [`format`]'s +/// when it reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). Importer plug-ins are +/// not looked at ([`importer_formats`]). pub fn readable_format(ext: &str) -> Option<&'static Format> { let k = ext.trim_start_matches('.').to_ascii_lowercase(); - format(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| format(id))) + builtin(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| builtin(id))) } /// The readable format a file name's extension names ([`readable_format`]). @@ -668,12 +669,38 @@ fn format_filters() -> impl Iterator &'static [&'static str] { + use vectorcraft_plugins::registry; + static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); + let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); + let revision = registry::revision(); + if let Some((r, exts)) = *made + && r == revision + { + return exts; + } + let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec(); + for e in importer_formats().iter().flat_map(|f| f.extensions.iter().copied()) { + if !exts.contains(&e) && readable_format(e).is_none() { + exts.push(e); + } + } + let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) }; + *made = Some((revision, exts)); + exts +} + +/// Open-dialog filters: "All readable files" first, then one per readable format, then one per +/// importer plug-in (under its name), then swatch libraries (which open in the library panel), +/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked +/// without running them) and plug-ins (installed). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)) + std::iter::once(("All readable files", open_extensions())) .chain(format_filters()) + .chain(importer_formats().into_iter().map(|f| (f.label, f.extensions))) .chain(std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS))) @@ -707,26 +734,40 @@ pub fn place_filters() -> impl Iterator Option<&'static Format> { let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase(); - FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| { + builtin(&k).or_else(|| { let added = plugin_formats(); - added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied() + let by_ext = |f: &&&Format| f.extensions.contains(&k.as_str()); + added + .iter() + .find(|f| f.id.eq_ignore_ascii_case(&k)) + .or_else(|| added.iter().filter(|f| f.write).find(by_ext)) + .or_else(|| added.iter().find(by_ext)) + .copied() }) } -/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in -/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for -/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues +/// A built-in format by id or extension (lower case, without the dot). +fn builtin(k: &str) -> Option<&'static Format> { + FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k))) +} + +/// The formats the installed exporter and importer plug-ins add ([`vectorcraft_plugins::export`], +/// [`vectorcraft_plugins::import`]), by plug-in id: an exporter's write only and an importer's read +/// only, with the plug-in's name as the label and its extensions. Each is made once for each +/// installation of its plug-in, its strings interned, so formats live as long as the catalogues /// that hold them. pub fn plugin_formats() -> Vec<&'static Format> { - use vectorcraft_plugins::{export, registry}; + use vectorcraft_plugins::{Kind, export, registry}; static MADE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); - export::installed() + registry::list() .iter() + .filter(|p| matches!(p.manifest().kind, Kind::Exporter | Kind::Importer)) .map(|p| { if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) { return *f; @@ -738,8 +779,8 @@ pub fn plugin_formats() -> Vec<&'static Format> { label: registry::intern(&m.name), extensions: Box::leak(extensions.into_boxed_slice()), mime: "application/octet-stream", - read: false, - write: true, + read: m.kind == Kind::Importer, + write: m.kind == Kind::Exporter, raster: false, options: &[], })); @@ -749,6 +790,11 @@ pub fn plugin_formats() -> Vec<&'static Format> { .collect() } +/// The formats the installed importer plug-ins add ([`plugin_formats`] that read), by plug-in id. +pub fn importer_formats() -> Vec<&'static Format> { + plugin_formats().into_iter().filter(|f| f.read).collect() +} + /// The lower-case extension of a file name or path (empty when it has none). pub fn extension(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -837,7 +883,7 @@ pub(super) fn formats(_: &mut Session, _: &Value) -> Result { "formats": all().map(listed).collect::>(), "readable": ids(|f| f.read), "writable": ids(|f| f.write), - "openExtensions": OPEN_EXTS, + "openExtensions": open_extensions(), "unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::>(), })) } diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs new file mode 100644 index 000000000..0a341c079 --- /dev/null +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -0,0 +1,115 @@ +//! `document.open` of the files importer plug-ins read ([`vectorcraft_plugins::import`]). +//! +//! A file opens with an importer when `importer` names one (its format id, `plugin.`), which +//! must list the file's extension, or when no built-in format reads the file, by its content or by +//! its extension, and an importer lists its extension (the one whose id sorts first, when several +//! do). A built-in format that can't read a damaged file reports its own error: the file is not +//! handed to an importer instead. Place reads only the built-in formats. +//! +//! The importer is given the file's bytes, at most [`import::MAX_INPUT_BYTES`] of them: a file +//! named by `path` that only an importer can read is read no further than that. Its objects become +//! a new document whose artboard is their bounds, and nothing opens when the importer fails, +//! declines, is cancelled or returns objects that don't pass the checks a filter's output does. + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::Document; +use vectorcraft_geom::Rect; +use vectorcraft_plugins::{Plugin, export, import, objects}; + +use super::super::{Result, bad}; +use super::load::{Loaded, detect, file_name}; +use super::{err, extension, file_stem, format}; +use crate::EngineError; + +const C: &str = "document.open"; + +/// The importer `document.open`'s params `p` name (`importer`), if any; a value that isn't a +/// string is an error. +fn named(p: &Value) -> Result> { + match p.get("importer").filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => v.as_str().map(Some).ok_or_else(|| bad(C, "`importer` must be a plug-in format id (see document.formats)")), + } +} + +/// The importer that opens the file `name` holding `bytes` with `document.open`'s params `p`: the +/// one `importer` names, which must list the file's extension; else, when no built-in format reads +/// the file, the first installed importer that lists its extension; else `None`. +pub(super) fn pick(name: &str, bytes: &[u8], p: &Value) -> Result>> { + let ext = extension(name); + if let Some(id) = named(p)? { + let plugin = import::named(id).map_err(|e| bad(C, e.to_string()))?; + let m = plugin.manifest(); + if !m.extensions.contains(&ext) { + let shown = vectorcraft_plugins::permissions::display_text(&m.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + let exts: Vec = m.extensions.iter().map(|e| format!(".{e}")).collect(); + return Err(bad(C, format!("“{shown}” opens {} files, not `{}`", exts.join(", "), file_name(name)))); + } + return Ok(Some(plugin)); + } + if detect(name, bytes).is_some() { + return Ok(None); + } + Ok(import::for_extension(&ext).into_iter().next()) +} + +/// The file at `path` for `document.open`'s params `p`. A file that only an importer reads (one +/// `importer` names, or an extension no built-in format reads that an importer lists) is read up +/// to [`import::MAX_INPUT_BYTES`]: past that, it is an error, unless the start of the file shows a +/// built-in format and no importer was named, which is read whole as any other file is. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn read_path(path: &str, p: &Value) -> Result> { + let ext = extension(path); + let explicit = named(p)?.is_some(); + if !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { + return super::read_file(path); + } + let head = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?; + if head.len() <= import::MAX_INPUT_BYTES { + return Ok(head); + } + if !explicit && detect(path, &head).is_some() { + return super::read_file(path); + } + Err(err(format!("can't open `{}`: {}", file_name(path), import::too_large()))) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn read_path(path: &str, _: &Value) -> Result> { + super::read_file(path) +} + +/// The file `name` holding `bytes` read by importer `plugin` with `document.open`'s params `p` +/// (`params`: the importer's own parameters) into a new document, its artboard the bounds of what +/// the importer returned. +pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Result { + let m = plugin.manifest(); + let format = format(&export::format_id(&m.id)).filter(|f| f.read).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(m.id.clone())))?; + let (ext, stem) = (extension(name), file_stem(name)); + let source = import::Source { extension: &ext, name: &stem }; + let params = p.get("params").cloned().unwrap_or(Value::Null); + let imported = import::run(plugin, bytes, ¶ms, source).map_err(|e| match e { + vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled, + vectorcraft_plugins::Error::Params(why) => bad(C, why), + e => err(format!("can't open `{}`: {e}", file_name(name))), + })?; + let mut doc = Document::new(1.0, 1.0); + let layer = doc.layers.first().map(|l| l.id); + let admit = |fx: &[vectorcraft_doc::Effect], n: &vectorcraft_doc::Node| crate::cmd::plugin::admit_effects_for(C, fx, n); + let ids = + objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) { + board.rect = at_least_a_point(bounds); + } + doc.title = file_name(name); + Ok(Loaded { doc, format, warnings: imported.warnings, restored: false, converted: false, preview_only: false, editing_data: false }) +} + +/// `r` at least 1 pt wide and high, about its center: the artboard of a single line or point. +fn at_least_a_point(r: Rect) -> Rect { + let grow = |lo: f64, hi: f64| if hi - lo >= 1.0 { (lo, hi) } else { ((lo + hi) / 2.0 - 0.5, (lo + hi) / 2.0 + 0.5) }; + let ((x0, x1), (y0, y1)) = (grow(r.x0, r.x1), grow(r.y0, r.y1)); + Rect::new(x0, y0, x1, y1) +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index a1ec671aa..f32a456b1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -555,10 +555,15 @@ impl ReadFile { /// thread): read and decode the file, read its linked files and lay out its type → what /// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update -/// Links set to Automatically; see [`crate::cmd::links::resolve`]). +/// Links set to Automatically; see [`crate::cmd::links::resolve`]). A file an importer plug-in +/// reads (`importer` in `p`, or an extension only an importer lists) is read by it +/// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = load_with(name, bytes, &opts)?; + let mut loaded = match super::importer::pick(name, bytes, p)? { + Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, + None => load_with(name, bytes, &opts)?, + }; let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) @@ -663,7 +668,11 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result> { } pub(super) fn open(s: &mut Session, p: &Value) -> Result { - let src = source(p, "document.open")?; + // A file only an importer plug-in reads is read up to the importer's input limit. + let src = match str_param(p, "path") { + Some(path) => Source { name: path, bytes: super::importer::read_path(path, p)?, path: Some(path) }, + None => source(p, "document.open")?, + }; // A relative path is the file in the working directory: its folder is where relative links // are found (a bare file name has no folder of its own). let path = src.path.map(super::absolute_path); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..fe6a562bf 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -7,7 +7,8 @@ //! plug-in that fails leaves the document untouched), and read and write the data they keep with //! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied //! like any effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`; -//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`). +//! exporters add a file format that `document.export` writes, and importers one that +//! `document.open` reads (`fileio::plugin_formats`). //! //! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing, //! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), @@ -49,7 +50,7 @@ pub fn describe(p: &Plugin) -> Value { }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), - Kind::Exporter => { + Kind::Exporter | Kind::Importer => { v["format"] = json!(export::format_id(&m.id)); v["extensions"] = json!(m.extensions); } @@ -128,6 +129,12 @@ impl FilterRun { format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)), )); } + Kind::Importer => { + return Err(bad( + C, + format!("`{}` is an importer: open a file with document.open {{\"importer\": \"{}\"}}", m.id, export::format_id(&m.id)), + )); + } } // Bad parameters fail before anything runs. let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); @@ -247,8 +254,13 @@ fn run(s: &mut Session, p: &Value) -> Result { /// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the /// host read one. fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, String> { + admit_effects_for("plugin.run", fx, n) +} + +/// [`admit_effects`] for command `cmd`, which bad parameters name: `plugin.run`, or `document.open` +/// for the objects an importer plug-in returns. +pub(crate) fn admit_effects_for(cmd: &str, fx: &[Effect], n: &Node) -> std::result::Result, String> { use vectorcraft_render::effects; - const C: &str = "plugin.run"; let mut out = Vec::with_capacity(fx.len()); for (j, e) in fx.iter().enumerate() { if n.appearance.effects.contains(e) { @@ -261,7 +273,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, St Some(plugin) => { plugin.manifest().resolve_params(¶ms).map_err(|why| refuse(why.to_string()))?; } - None => super::params::check_effect_params(C, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, + None => super::params::check_effect_params(cmd, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, } if super::effectcmd::texture_load_effect(&e.id) && let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE) @@ -493,7 +505,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..2a4a5f991 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -242,6 +242,24 @@ proptest! { renders(&s, &out)?; } + /// An importer's hostile output (`document.open`) either opens a document that renders and + /// exports, or is an error that opens nothing. + #[test] + fn hostile_importer_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + ) { + let mut s = Session::new(); + let out = output(&objects, &[], junk); + install(&mut s, &wat::parse_str(tpl::returning(&tpl::importer("fuzz.importer", r#"["fzi"]"#, "{}"), &out)).unwrap()).unwrap(); + let r = catch_quiet(|| s.execute("document.open", &json!({"name": "x.fzi", "dataBase64": ""})).map(|_| ())); + match r { + Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))), + Ok(Err(_)) => prop_assert!(s.documents().is_empty(), "a failed open opened a document: {}", out), + Ok(Ok(())) => renders(&s, &out)?, + } + } + #[test] fn mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8), @@ -276,10 +294,10 @@ proptest! { /// character from the name. #[test] fn hostile_manifest_permissions_never_crash( - kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + kind in prop::sample::select(vec!["filter", "effect", "exporter", "importer"]), names in prop::collection::vec( prop_oneof![ - prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]), + prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress", "ui.panel", ""]), Just("doc.read\u{202E}"), Just("\u{0007}"), ], @@ -288,10 +306,10 @@ proptest! { permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![3 => Just(json!(1)), 1 => arb_junk()], name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], - grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]))], + grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read"]))], ) { let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); - if kind == "exporter" { + if matches!(kind, "exporter" | "importer") { m["extensions"] = json!(["fz"]); } m["permissions"] = match permissions { diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs new file mode 100644 index 000000000..f56684172 --- /dev/null +++ b/crates/engine/tests/plugin_import.rs @@ -0,0 +1,252 @@ +//! Importer plug-ins (`"kind": "importer"`, docs/plugins.md › Importers) through the engine: the +//! formats they add to `document.formats` and `document.open`, which files they are given, the +//! document their objects become, and what happens when they fail, decline or are cancelled. +//! +//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the +//! exact list of formats would see these ones. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::{EngineError, Session}; +use vectorcraft_plugins::wat::{self as tpl, Given}; + +/// An importer's output: one red rectangle from (10, 20) to (110, 70) and a warning. +const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]},"fills":[{"type":"solid","color":{"model":"rgb","r":1,"g":0,"b":0}}]}],"warnings":["2 layers merged"]}"#; + +/// An importer's output: a horizontal line from (0, 5) to (50, 5). +const LINE: &str = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,5]},{"p":[50,5]}]}]}}]}"#; + +/// Installs the plug-in written in WAT `src` → its `plugin.list` entry. +fn install(s: &mut Session, src: &str) -> Value { + let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}")); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap() +} + +/// An importer `id` reading `extensions` (a JSON list) whose objects are the file it is given. +fn echo(s: &mut Session, id: &str, extensions: &str) -> Value { + install(s, &tpl::module(&tpl::importer(id, extensions, "{}"), tpl::ECHO, "", 1)) +} + +/// `document.open` of a file `name` holding `bytes`, with more params `extra`. +fn open(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Result { + let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + s.execute("document.open", &p) +} + +/// The active document's first artboard, as `(x0, y0, x1, y1)`. +fn artboard(s: &Session) -> (f64, f64, f64, f64) { + let r = s.doc().unwrap().doc.artboards[0].rect; + (r.x0, r.y0, r.x1, r.y1) +} + +/// How many objects the active document's first layer holds. +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers[0].children().unwrap().len() +} + +/// A fresh folder for files this test writes. +fn tmp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn path_of(p: &std::path::Path) -> String { + p.to_string_lossy().into_owned() +} + +#[test] +fn an_importer_adds_a_read_only_format_while_it_is_installed() { + let mut s = Session::new(); + let entry = echo(&mut s, "test.list", r#"["tsl","tsl2"]"#); + assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("importer"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"]))); + assert_eq!(entry["permissions"], json!(["import.read"])); + let formats = s.execute("document.formats", &json!({})).unwrap(); + let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed"); + assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"]))); + assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(true), &json!(false), &json!("test.list"))); + assert!(formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(formats["openExtensions"].as_array().unwrap().contains(&json!("tsl2"))); + assert!(fileio::open_extensions().contains(&"tsl")); + assert!(!fileio::export_extensions().contains(&"tsl")); + assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case"); + + s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap(); + let formats = s.execute("document.formats", &json!({})).unwrap(); + assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(!formats["openExtensions"].as_array().unwrap().contains(&json!("tsl"))); + assert!(fileio::format("tsl").is_none()); + assert!(open(&mut s, "a.tsl", RECT.as_bytes(), json!({})).is_err()); + assert!(s.documents().is_empty()); +} + +/// A file of an extension only the importer lists opens as a new document of editable art, its +/// artboard the art's bounds, with the importer's warnings; Save then asks for a name. +#[test] +fn a_file_an_importer_reads_opens_as_editable_art() { + let dir = tmp_dir("open"); + let mut s = Session::new(); + echo(&mut s, "test.open", r#"["tso"]"#); + let r = open(&mut s, "Shapes.tso", RECT.as_bytes(), json!({})).unwrap(); + assert_eq!((&r["format"], &r["warnings"], &r["restored"]), (&json!("plugin.test.open"), &json!(["2 layers merged"]), &json!(false))); + assert_eq!(s.documents().len(), 1); + assert_eq!(objects(&s), 1); + assert_eq!(artboard(&s), (10.0, 20.0, 110.0, 70.0)); + // Editable: the rectangle moves like any path. + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + + // A line's artboard is at least 1 pt high. + open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap(); + assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5)); + + // From a path: the document keeps no path, so Save asks for a name. + let path = dir.join("Shapes.tso"); + std::fs::write(&path, RECT).unwrap(); + let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap(); + assert_eq!(r["format"], "plugin.test.open"); + assert_eq!(s.doc().unwrap().path, None); + assert!(s.doc().unwrap().doc.title.contains("Shapes"), "{}", s.doc().unwrap().doc.title); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A built-in format keeps its files: an importer that lists `svg` is given an `.svg` only when +/// `importer` names it, and a file with a built-in format's content opens with the built-in reader +/// whatever its extension. +#[test] +fn built_in_formats_keep_their_files() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.svgtoo", r#"["tsv","svg"]"#, "{}"), RECT)); + let svg = br#""#; + assert_eq!(open(&mut s, "a.svg", svg, json!({})).unwrap()["format"], "svg"); + // A damaged SVG reports the SVG reader's error; the importer isn't tried instead. + match open(&mut s, "b.svg", b" assert_eq!(r["format"], "svg"), + Err(e) => assert!(!e.to_string().contains("importer"), "{e}"), + } + // Named, the importer opens an .svg. + let r = open(&mut s, "c.svg", svg, json!({"importer": "plugin.test.svgtoo"})).unwrap(); + assert_eq!(r["format"], "plugin.test.svgtoo"); + // An SVG named as the importer's file is still an SVG. + assert_eq!(open(&mut s, "d.tsv", svg, json!({})).unwrap()["format"], "svg"); + assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg")); + assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1); +} + +/// Several importers list one extension: the one whose id sorts first opens the file, and +/// `importer` picks another (by its format id or its id). +#[test] +fn the_first_importer_by_id_opens_a_shared_extension() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.shared.b", r#"["tsh"]"#, "{}"), LINE)); + install(&mut s, &tpl::returning(&tpl::importer("test.shared.a", r#"["tsh"]"#, "{}"), RECT)); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({})).unwrap()["format"], "plugin.test.shared.a"); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "plugin.test.shared.b"})).unwrap()["format"], "plugin.test.shared.b"); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "test.shared.b"})).unwrap()["format"], "plugin.test.shared.b"); +} + +#[test] +fn importer_names_that_do_not_fit_are_errors() { + let mut s = Session::new(); + echo(&mut s, "test.names", r#"["tsn"]"#); + install(&mut s, &tpl::returning(&tpl::manifest("test.names.filter", "filter", "{}"), r#"{"objects":[]}"#)); + let cases = [ + (json!({"importer": "plugin.test.names"}), "x.png", "opens .tsn files, not `x.png`"), + (json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a filter, not an importer"), + (json!({"importer": "plugin.test.nothere"}), "x.tsn", "test.nothere"), + (json!({"importer": 3}), "x.tsn", "`importer` must be a plug-in format id"), + ]; + for (extra, name, why) in cases { + let e = open(&mut s, name, RECT.as_bytes(), extra.clone()).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{extra}: {e}"); + assert!(e.to_string().contains(why), "{extra}: {e}"); + } + assert!(s.documents().is_empty()); + // Without an importer, a file nothing reads stays unsupported. + assert!(open(&mut s, "x.tsunknown", RECT.as_bytes(), json!({})).is_err()); + // An importer is not a filter. + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("plugin.run", &json!({"id": "test.names"})).unwrap_err(); + assert!(e.to_string().contains("document.open"), "{e}"); +} + +/// The importer is given its parameters, checked against its manifest, and `_context` with the +/// file's extension and name. +#[test] +fn the_importer_is_given_its_parameters_and_the_file_s_name() { + let mut s = Session::new(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#]; + for needle in needles { + // RECT when the parameters hold the needle; the file, which isn't JSON, otherwise. + install(&mut s, &tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)); + let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}})); + assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}"); + } + let before = s.documents().len(); + let e = open(&mut s, "Shapes.tsc", b"", json!({"params": {"density": "lots"}})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); + assert_eq!(s.documents().len(), before); +} + +#[test] +fn failures_open_nothing() { + let mut s = Session::new(); + let far = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[9000000,0]}]}]}}]}"#; + let bad_warnings = LINE.replace("]}]}}]}", r#"]}]}}],"warnings":"no"}"#); + let cases = [ + ("test.fail.declines", r#"{"error":"Not a drawing."}"#, "Not a drawing."), + ("test.fail.notjson", "not json", "JSON"), + ("test.fail.empty", r#"{"objects":[]}"#, "the importer returned no objects"), + ("test.fail.key", r#"{"objects":[],"layers":[]}"#, "an importer returns `objects` and `warnings`, not `layers`"), + ("test.fail.id", r#"{"objects":[{"id":3}]}"#, "is not the id of an input object"), + ("test.fail.far", far, "4000000"), + ("test.fail.warnings", bad_warnings.as_str(), "`warnings` must be a list of strings"), + ]; + for (id, output, why) in cases { + install(&mut s, &tpl::returning(&tpl::importer(id, r#"["tsf"]"#, "{}"), output)); + let e = open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why), "{id}: {e}"); + assert!(e.to_string().contains("x.tsf"), "{id}: names the file: {e}"); + } + install(&mut s, &tpl::module(&tpl::importer("test.fail.traps", r#"["tsf"]"#, "{}"), "(unreachable)", "", 1)); + assert!(open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.traps"})).is_err()); + assert!(s.documents().is_empty(), "nothing opened"); +} + +/// Cancel stops an importer between two slices of its budget, and nothing opens: here one that +/// never returns, run with a token cancelled beforehand. +#[test] +fn a_cancelled_open_stops_the_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert!(s.documents().is_empty()); +} + +/// A file past the importer's input limit is not read past it, and doesn't open. +#[test] +fn a_file_past_the_input_limit_is_refused() { + let dir = tmp_dir("large"); + let mut s = Session::new(); + echo(&mut s, "test.large", r#"["tsb"]"#); + let path = dir.join("big.tsb"); + std::fs::File::create(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert!(e.to_string().contains("big.tsb"), "{e}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs new file mode 100644 index 000000000..5a7be6a85 --- /dev/null +++ b/crates/plugins/src/import.rs @@ -0,0 +1,182 @@ +//! Importer plug-ins: a plug-in of kind `importer` reads files in a format of its own, such as a +//! pen plotter's or a cutting machine's, into a new document. +//! +//! File › Open opens a file with an importer only when no built-in format reads it: the built-in +//! formats are found by the file's content first and then by its extension, and an importer is +//! picked by an extension it lists. When several importers list the extension, the one whose id +//! sorts first opens it ([`for_extension`]). File › Open With and `document.open {importer}` pick an +//! importer by its format id (`plugin.`), also for an extension a built-in format reads. +//! +//! The host hands the importer the file's bytes as they are, at most [`MAX_INPUT_BYTES`] of them, +//! and reads back the objects of the new document in the same form filters return them +//! ([`crate::objects`]): `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline. +//! An import is one run the user asked for, so it gets an export's budget ([`crate::export::FUEL`] +//! and [`crate::export::WALL_TIME_MS`]), and Cancel stops it between two slices of the budget +//! ([`Error::Cancelled`]). + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::objects::{OutObject, declined, printable}; +use crate::{Error, Kind, Limits, Plugin, Result, registry}; + +/// The largest file an importer is given, in bytes: 128 MiB, a quarter of a plug-in's memory +/// limit, so the file and the objects made from it fit beside each other. +pub const MAX_INPUT_BYTES: usize = 128 << 20; + +/// The keys an importer's output may have. +const OUTPUT_KEYS: &[&str] = &["objects", "warnings", "error"]; + +/// The installed importers, by plug-in id. +pub fn installed() -> Vec> { + registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Importer).collect() +} + +/// The installed importer of format id `format_id` (`plugin.`). +pub fn by_format(format_id: &str) -> Option> { + registry::get(crate::export::plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Importer) +} + +/// The installed importer `importer` names: its format id (`plugin.`) or its plug-in id. A +/// plug-in of another kind, and one that isn't installed, are errors that say so. +pub fn named(importer: &str) -> Result> { + let id = crate::export::plugin_id(importer).unwrap_or(importer); + let plugin = registry::get(id).ok_or_else(|| Error::NotFound(id.into()))?; + match plugin.manifest().kind { + Kind::Importer => Ok(plugin), + _ => Err(not_importer(plugin.manifest())), + } +} + +/// The error of plug-in `m`, which is not an importer, asked to import. +fn not_importer(m: &crate::Manifest) -> Error { + Error::Params(format!("`{}` is a {}, not an importer", m.id, m.kind.name())) +} + +/// The installed importers that list extension `ext` (any case, leading dot allowed), by plug-in +/// id: the first is the one File › Open uses. +pub fn for_extension(ext: &str) -> Vec> { + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + installed().into_iter().filter(|p| p.manifest().extensions.contains(&ext)).collect() +} + +/// What an importer returned. +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + /// The objects of the new document, in paint order (bottom first), checked as a filter's are. + pub objects: Vec, + /// What the import left out or changed, for the user (at most + /// [`crate::export::MAX_WARNINGS`]). + pub warnings: Vec, +} + +/// The file an import reads, as the importer is told in `_context`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Source<'a> { + /// Its extension, without the dot: one the importer lists. + pub extension: &'a str, + /// Its name without the folder and extension. + pub name: &'a str, +} + +/// The error of a file larger than [`MAX_INPUT_BYTES`], which no importer is given. +pub fn too_large() -> Error { + Error::Io(format!("the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads")) +} + +/// The limits of an import by `plugin`: its own, with the budgets of an export. +pub fn limits(plugin: &Plugin) -> Limits { + crate::export::limits(plugin) +} + +/// Runs importer `plugin` on `file`, the bytes of the file being opened, with `params` (checked +/// against its manifest: missing ones take their defaults) for the file `source`. +pub fn run(plugin: &Plugin, file: &[u8], params: &Value, source: Source) -> Result { + let m = plugin.manifest(); + if m.kind != Kind::Importer { + return Err(not_importer(m)); + } + if file.len() > MAX_INPUT_BYTES { + return Err(too_large()); + } + let mut params = Value::Object(m.resolve_params(params)?); + params["_context"] = json!({"mode": "import", "extension": source.extension, "name": source.name}); + let params = serde_json::to_vec(¶ms).map_err(|e| Error::Params(e.to_string()))?; + decode(&plugin.run_limited(file, ¶ms, &limits(plugin))?) +} + +/// An importer's output: `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline +/// with a message for the user. The objects are new ones (no `id`), checked as a filter's are; an +/// output without any, or with keys of its own, is refused. +pub fn decode(bytes: &[u8]) -> Result { + let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; + if let Some(e) = declined(&v) { + return Err(e); + } + if let Some(key) = v.as_object().and_then(|o| o.keys().find(|k| !OUTPUT_KEYS.contains(&k.as_str()))) { + let key = printable(&json!(key), 64); + return Err(Error::Output(format!("an importer returns `objects` and `warnings`, not `{key}`"))); + } + let objects = crate::objects::decode(bytes)?; + if objects.is_empty() { + return Err(Error::Output("the importer returned no objects".into())); + } + let warnings = match v.get("warnings") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(w)) => w.iter().take(crate::export::MAX_WARNINGS).map(|w| printable(w, crate::export::MAX_WARNING_CHARS)).collect(), + Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())), + }; + Ok(Imported { objects, warnings }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SQUARE: &str = r#"{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]}]}]}}"#; + + #[test] + fn output_is_the_objects_and_the_warnings_or_a_decline() { + let out = decode(format!(r#"{{"objects":[{SQUARE}],"warnings":["a",2]}}"#).as_bytes()).unwrap(); + assert_eq!((out.objects.len(), out.warnings), (1, vec!["a".to_string(), "2".to_string()])); + assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some())); + assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::::new()); + assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into()))); + } + + /// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys + /// the host doesn't read, bad warnings and bad objects (coordinates out of range, unknown paints) + /// are all errors. + #[test] + fn bad_output_is_refused() { + let far = r#"{"objects":[{"path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#; + for (bad, says) in [ + (&b"[]"[..], "objects"), + (b"{}", "objects"), + (b"not json", "not JSON"), + (br#"{"objects":[]}"#, "no objects"), + (br#"{"objects":{}}"#, "objects"), + (br#"{"objects":[1]}"#, "JSON object"), + (far.as_bytes(), "out of range"), + (br#"{"objects":[{"path":{"subpaths":[]},"fills":[{"type":"hatch"}]}]}"#, "objects[0]"), + (br#"{"objects":[],"artboard":[0,0,1,1]}"#, "not `artboard`"), + ] { + let e = decode(bad).unwrap_err().to_string(); + assert!(e.contains(says), "{}: {e}", String::from_utf8_lossy(bad)); + } + let warns = format!(r#"{{"objects":[{SQUARE}],"warnings":"x"}}"#); + assert!(decode(warns.as_bytes()).unwrap_err().to_string().contains("`warnings`")); + } + + #[test] + fn warnings_are_capped_in_number_and_length() { + let many: Vec = (0..crate::export::MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect(); + let square: Value = serde_json::from_str(SQUARE).unwrap(); + let out = decode(json!({"objects": [square], "warnings": many}).to_string().as_bytes()).unwrap(); + assert_eq!(out.warnings.len(), crate::export::MAX_WARNINGS); + let long = "x".repeat(crate::export::MAX_WARNING_CHARS + 1); + let out = decode(json!({"objects": [square], "warnings": [long]}).to_string().as_bytes()).unwrap(); + assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(crate::export::MAX_WARNING_CHARS)); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..47037c4db 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -19,7 +19,8 @@ //! the objects to keep, change or add, applied to the document as one edit //! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each //! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole -//! document in a file format of its own. The process-wide [`registry`] holds the installed +//! document in a file format of its own; an **importer** ([`import`]) reads a file in a format of +//! its own into a new document. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is //! the set of [`permissions`] its manifest lists, which the user approves when it is installed. @@ -28,6 +29,7 @@ pub mod dialog; pub mod effect; pub mod export; +pub mod import; pub mod manifest; pub mod objects; pub mod permissions; @@ -40,7 +42,7 @@ pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect -/// (`plugin.`, [`effect`]) or its file format ([`export`]). +/// (`plugin.`, [`effect`]) or its file format ([`export`], [`import`]). pub const PREFIX: &str = "plugin."; /// A plug-in error. Every way a module can misbehave ends here, never in a panic. diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..de4013869 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -18,7 +18,7 @@ pub const MAX_UNGROUPED: usize = 64; pub const MAX_TEXT: usize = 4_096; /// Most numbers a `numberList` parameter may hold. pub const MAX_LIST: usize = 256; -/// Most file extensions an exporter may declare. +/// Most file extensions an exporter or an importer may declare. pub const MAX_EXTENSIONS: usize = 8; /// What a plug-in is. @@ -34,6 +34,9 @@ pub enum Kind { /// An exporter: writes the document in a file format of its own, whose extensions the manifest /// lists (File › Export › Export As, `document.export`; see [`crate::export`]). Exporter, + /// An importer: reads a file in a format of its own, whose extensions the manifest lists, into + /// a new document (File › Open, `document.open`; see [`crate::import`]). + Importer, } impl Kind { @@ -42,6 +45,7 @@ impl Kind { Kind::Filter => "filter", Kind::Effect => "effect", Kind::Exporter => "exporter", + Kind::Importer => "importer", } } } @@ -253,8 +257,8 @@ pub struct Manifest { /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, - /// An exporter's file extensions, lower case and without the dot; the first is the one its - /// files get. Only exporters declare them. + /// An exporter's or an importer's file extensions, lower case and without the dot; the first is + /// the one an exporter's files get. Only exporters and importers declare them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extensions: Vec, /// How much a run may compute: only a filter may be [`Budget::Heavy`]. @@ -319,6 +323,9 @@ impl Manifest { (Budget::Heavy, Kind::Exporter) => { return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); } + (Budget::Heavy, Kind::Importer) => { + return Err(Error::Manifest("an importer always gets an import's budget: leave `budget` out".into())); + } } for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { @@ -333,17 +340,17 @@ impl Manifest { Ok(()) } - /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case - /// letters and digits; other kinds list none. + /// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 + /// lower-case letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { let exts = &self.extensions; + let count_ok = !exts.is_empty() && exts.len() <= MAX_EXTENSIONS; match self.kind { - Kind::Exporter => {} + Kind::Exporter if !count_ok => return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))), + Kind::Importer if !count_ok => return Err(Error::Manifest(format!("an importer declares 1 to {MAX_EXTENSIONS} `extensions`"))), + Kind::Exporter | Kind::Importer => {} _ if exts.is_empty() => return Ok(()), - _ => return Err(Error::Manifest("only an exporter declares `extensions`".into())), - } - if exts.is_empty() || exts.len() > MAX_EXTENSIONS { - return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))); + _ => return Err(Error::Manifest("only an exporter or an importer declares `extensions`".into())), } for (i, e) in exts.iter().enumerate() { if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) { @@ -608,6 +615,31 @@ mod tests { } } + /// An importer lists the extensions it reads by the exporter's rules, and gets an import's + /// budget and the `import.read` permission. + #[test] + fn an_importer_lists_its_extensions() { + let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"importer","extensions":["plt","hpgl"]}"#).unwrap(); + assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Importer, &["plt".to_string(), "hpgl".to_string()][..])); + assert_eq!(serde_json::to_value(&m).unwrap()["kind"], json!("importer")); + assert_eq!(m.permissions(), [Permission::ImportRead]); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"importer"}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":[]}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["a","b","c","d","e","f","g","h","i"]}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["Plt"]}"#, "lower-case"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":[".plt"]}"#, "without the dot"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["abcdefghijklmnopq"]}"#, "1-16"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt","plt"]}"#, "listed twice"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"budget":"heavy"}"#, "an import's budget"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"permissions":["doc.read"]}"#, "doesn't apply"), + (r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#, "only an exporter or an importer"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + } + /// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an /// exporter has an export's budget, so they can't. Light, the default, isn't written back. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 1d6b0664e..540a091a3 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -47,10 +47,14 @@ pub enum Permission { /// Write the document as a file in its own format (an exporter). #[serde(rename = "export.write")] ExportWrite, + /// Read a file the user opens and make a new document from it (an importer). + #[serde(rename = "import.read")] + ImportRead, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 5] = [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite]; +pub const ALL: [Permission; 6] = + [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::ImportRead]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -61,6 +65,7 @@ impl Permission { Permission::State => "state", Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", + Permission::ImportRead => "import.read", } } @@ -77,6 +82,7 @@ impl Permission { Permission::State => "Keep data of its own with the document", Permission::EffectRender => "Change how the objects its effect is applied to are drawn", Permission::ExportWrite => "Write the document as a file in its own format", + Permission::ImportRead => "Read files you open in its format and make documents from them", } } @@ -87,16 +93,17 @@ impl Permission { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], Kind::Effect => &[Permission::DocRead, Permission::EffectRender], Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite], + Kind::Importer => &[Permission::ImportRead], } } /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live - /// effect and an exporter are always handed objects, and only draw or write. A filter may - /// leave any of its permissions out. + /// effect and an exporter are always handed objects, and only draw or write; an importer is + /// always handed the file being opened. A filter may leave any of its permissions out. fn required(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[], - Kind::Effect | Kind::Exporter => Permission::implied(kind), + Kind::Effect | Kind::Exporter | Kind::Importer => Permission::implied(kind), } } } @@ -218,7 +225,11 @@ mod tests { assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Importer, &list(&["import.read"])).unwrap(), [Permission::ImportRead]); for (kind, names, says) in [ + (Kind::Importer, &["import.read", "doc.read"][..], "`doc.read` doesn't apply to a plug-in of kind `importer`"), + (Kind::Importer, &[], "needs the permission `import.read`"), + (Kind::Exporter, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"), (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), (Kind::Filter, &["progress"], "`progress` is reserved"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0201e1a50..13570d36f 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -117,6 +117,11 @@ pub fn exporter(id: &str, extensions: &str, params: &str) -> String { format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#) } +/// The manifest of a test importer `id` reading `extensions` (a JSON list) with `params`. +pub fn importer(id: &str, extensions: &str, params: &str) -> String { + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"importer","extensions":{extensions},"params":{params}}}"#) +} + /// What [`echo_exporter`] writes as its file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Echo { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ed9f909b3..c98a23887 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -803,6 +803,8 @@ mod tests_plugin_export; #[cfg(test)] mod tests_plugin_fields; #[cfg(test)] +mod tests_plugin_import; +#[cfg(test)] mod tests_raster_formats; #[cfg(test)] mod tests_svg_options_range; diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..8096f1e58 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -8,7 +8,8 @@ //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: //! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its -//! Export button writes the file. +//! Export button writes the file. Importers open the files File › Open finds no built-in format +//! for, and File › Open With lists them, to open a file with one of them by name. use std::sync::Arc; @@ -56,6 +57,18 @@ pub fn object_menu() -> Vec { items } +/// File › Open With's header while no importer plug-in is installed. +pub const NO_IMPORTERS: &str = "No importer plug-ins installed"; + +/// File › Open With: the installed importers (`file.openWith {importer}`), each by its name. +pub fn open_with_menu() -> Vec { + let items: Vec = vectorcraft_plugins::import::installed() + .iter() + .map(|p| Item::Cmd(label(&p.manifest().name, true), "file.openWith", json!({ "importer": vectorcraft_plugins::export::format_id(p.id()) }))) + .collect(); + if items.is_empty() { vec![Item::Header(NO_IMPORTERS)] } else { items } +} + /// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without /// options apply at once, as built-in ones do. pub fn effect_menu() -> Option { @@ -79,6 +92,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Kind::Filter => {} Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)), Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)), + Kind::Importer => return Err(format!("{} is an importer (File › Open With)", m.name)), } let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); if m.params.is_empty() || given.is_some() { diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs new file mode 100644 index 000000000..7d2e7b9a1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -0,0 +1,103 @@ +//! Importer plug-ins in File › Open With and File › Open: listed by name under Open With, which +//! opens a file with the importer also when a built-in format reads its extension, and a file only +//! an importer reads is not read past the importer's input limit. Each test installs importers of +//! its own and removes them: plug-ins are process-wide, and an importer left installed would show +//! in other tests' Open dialog. + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +use super::*; +use crate::Services; +use crate::menus::Item; + +/// An importer's output: one rectangle from (10, 20) to (110, 70). +const RECT: &str = + r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; + +/// An app that reads files from disk, refusing to read one past the importers' input limit. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| match std::fs::metadata(p) { + Ok(m) if m.len() > vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 => Err("read past the limit".into()), + _ => std::fs::read(p).map_err(|e| e.to_string()), + })); + app +} + +fn install(app: &mut VectorcraftApp, src: &str) { + let wasm = wat::parse_str(src).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); +} + +/// A fresh folder for files this test writes. +fn tmp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-ui-plugin-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The active document's first artboard, as `(x0, y0, x1, y1)`. +fn artboard(app: &VectorcraftApp) -> (f64, f64, f64, f64) { + let r = app.session.doc().unwrap().doc.artboards[0].rect; + (r.x0, r.y0, r.x1, r.y1) +} + +/// Open With lists an importer by name; it opens an `.svg` with the importer, where File › Open +/// keeps the built-in SVG reader. +#[test] +fn open_with_lists_importers_and_opens_a_file_with_one() { + let dir = tmp_dir("with"); + let mut app = app(); + install(&mut app, &tpl::returning(&tpl::importer("test.ui.svgreader", r#"["svg","tui"]"#, "{}"), RECT)); + let menu = plugin::open_with_menu(); + let listed = menu.iter().any(|i| match i { + Item::Cmd(label, "file.openWith", p) => *label == "Test test.ui.svgreader…" && p["importer"] == "plugin.test.ui.svgreader", + _ => false, + }); + assert!(listed, "{menu:?}"); + + let svg = dir.join("a.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let svg = svg.to_string_lossy().into_owned(); + crate::io::open_path(&mut app, &svg).unwrap(); + assert_eq!(artboard(&app), (0.0, 0.0, 50.0, 40.0), "File › Open: the built-in SVG reader"); + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.svgreader", "path": svg})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.svgreader"); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); + assert_eq!(app.session.documents().len(), 2); + + // An importer is not a filter: Object › Plug-ins' command says where it is. + let e = app.run("plugin.dialog", json!({"id": "test.ui.svgreader"})).unwrap_err(); + assert!(e.contains("is an importer (File › Open With)"), "{e}"); + let e = app.run("file.openWith", json!({"path": svg})).unwrap_err(); + assert!(e.contains("missing `importer`"), "{e}"); + app.run("plugin.remove", json!({"id": "test.ui.svgreader"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File › Open reads a file of an extension only an importer lists, and refuses one past the +/// importer's input limit before reading it, as Open With does. +#[test] +fn file_open_refuses_an_importer_s_file_past_the_limit_before_reading_it() { + let dir = tmp_dir("limit"); + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.big", r#"["tub"]"#, "{}"), tpl::ECHO, "", 1)); + let small = dir.join("small.tub"); + std::fs::write(&small, RECT).unwrap(); + crate::io::open_path(&mut app, &small.to_string_lossy()).unwrap(); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); + + let big = dir.join("big.tub"); + std::fs::File::create(&big).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let big = big.to_string_lossy().into_owned(); + let e = crate::io::open_path(&mut app, &big).unwrap_err(); + assert!(e.contains("larger than"), "{e}"); + let e = app.run("file.openWith", json!({"importer": "plugin.test.ui.big", "path": big})).unwrap_err(); + assert!(e.contains("larger than"), "{e}"); + assert_eq!(app.session.documents().len(), 1); + app.run("plugin.remove", json!({"id": "test.ui.big"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 19d428340..925865744 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6282,3 +6282,8 @@ breakText Glyphs Глифи # 3D Box add-on 3D Box 3D кутия 3D Box Tool Инструмент за 3D кутия + +# Importer plug-ins (File › Open With, document.open) + Open With Отваряне с + No importer plug-ins installed Няма инсталирани плъгини за импортиране + Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 50553d5bf..c69f6ea9a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6329,3 +6329,8 @@ shape Open Otvoren # 3D Box add-on 3D Box 3D kutija 3D Box Tool Alat za 3D kutiju + +# Importer plug-ins (File › Open With, document.open) + Open With Otvori pomoću + No importer plug-ins installed Nisu instalirani dodaci za uvoz + Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 324750c44..222971582 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6430,3 +6430,20 @@ breakText Glyphs Glifs # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Eina Caixa 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Obre amb + No importer plug-ins installed No hi ha complements d’importació instal·lats + Read files you open in its format and make documents from them Llegir els fitxers que obriu en el seu format i fer-ne documents +@msg {_1} is an importer (File › Open With) {_1} és un importador (Fitxer › Obre amb) +@msg missing `importer` (see document.formats) falta `importer` (vegeu document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` ha de ser l’id de format d’un complement (vegeu document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` és un {_2}, no un importador +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} +@msg the importer returned no objects l’importador no ha retornat cap objecte +@msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un importador declara d’1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 78e9cdd6e..eb3dd4da2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6444,3 +6444,20 @@ breakText Glyphs Glyfy # 3D Box add-on 3D Box 3D kvádr 3D Box Tool Nástroj 3D kvádr + +# Importer plug-ins (File › Open With, document.open) + Open With Otevřít pomocí + No importer plug-ins installed Nejsou nainstalovány žádné importní zásuvné moduly + Read files you open in its format and make documents from them Číst soubory, které otevřete v jeho formátu, a vytvářet z nich dokumenty +@msg {_1} is an importer (File › Open With) {_1} je importní zásuvný modul (Soubor › Otevřít pomocí) +@msg missing `importer` (see document.formats) chybí `importer` (viz document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` musí být ID formátu zásuvného modulu (viz document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ otevírá soubory {_1}, ne `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` je {_2}, ne importní zásuvný modul +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer"}"}} +@msg the importer returned no objects importní zásuvný modul nevrátil žádné objekty +@msg an importer returns `objects` and `warnings`, not `{key}` importní zásuvný modul vrací `objects` a `warnings`, ne `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads soubor je větší než {MAX_INPUT_BYTES} bajtů, které importní zásuvný modul čte +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` importní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul +@msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f72f29a09..a36911a35 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6296,3 +6296,8 @@ breakText Glyphs Glyffer # 3D Box add-on 3D Box 3D-boks 3D Box Tool 3D-boksværktøj + +# Importer plug-ins (File › Open With, document.open) + Open With Åbn med + No importer plug-ins installed Ingen import-plug-ins installeret + Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b22912c68..720f94a16 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6030,3 +6030,20 @@ breakText Glyphs Glyphen # 3D Box add-on 3D Box 3D-Quader 3D Box Tool 3D-Quader-Werkzeug + +# Importer plug-ins (File › Open With, document.open) + Open With Öffnen mit + No importer plug-ins installed Keine Import-Plug-ins installiert + Read files you open in its format and make documents from them Dateien lesen, die Sie in seinem Format öffnen, und daraus Dokumente erstellen +@msg {_1} is an importer (File › Open With) {_1} ist ein Import-Plug-in (Datei › Öffnen mit) +@msg missing `importer` (see document.formats) `importer` fehlt (siehe document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` muss die Format-ID eines Plug-ins sein (siehe document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ öffnet {_1}-Dateien, nicht `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` ist ein {_2}, kein Import-Plug-in +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer"}"}} +@msg the importer returned no objects das Import-Plug-in hat keine Objekte zurückgegeben +@msg an importer returns `objects` and `warnings`, not `{key}` ein Import-Plug-in gibt `objects` und `warnings` zurück, nicht `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads die Datei ist größer als die {MAX_INPUT_BYTES} Byte, die ein Import-Plug-in liest +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ein Import-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an +@msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an +@msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 71e4f499f..22a6511d3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6396,3 +6396,20 @@ breakText Glyphs Γλύφοι # 3D Box add-on 3D Box Κουτί 3D 3D Box Tool Εργαλείο κουτιού 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Άνοιγμα με + No importer plug-ins installed Δεν έχουν εγκατασταθεί πρόσθετα εισαγωγής + Read files you open in its format and make documents from them Ανάγνωση των αρχείων που ανοίγετε στη μορφή του και δημιουργία εγγράφων από αυτά +@msg {_1} is an importer (File › Open With) το {_1} είναι πρόσθετο εισαγωγής (Αρχείο › Άνοιγμα με) +@msg missing `importer` (see document.formats) λείπει το `importer` (δείτε document.formats) +@msg `importer` must be a plug-in format id (see document.formats) το `importer` πρέπει να είναι αναγνωριστικό μορφής πρόσθετου (δείτε document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` το «{shown}» ανοίγει αρχεία {_1}, όχι `{_2}` +@msg `{_1}` is a {_2}, not an importer το `{_1}` είναι {_2}, όχι πρόσθετο εισαγωγής +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer"}"}} +@msg the importer returned no objects το πρόσθετο εισαγωγής δεν επέστρεψε κανένα αντικείμενο +@msg an importer returns `objects` and `warnings`, not `{key}` ένα πρόσθετο εισαγωγής επιστρέφει `objects` και `warnings`, όχι `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads το αρχείο είναι μεγαλύτερο από τα {MAX_INPUT_BYTES} byte που διαβάζει ένα πρόσθετο εισαγωγής +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εισαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions` +@msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 72ad8d982..d574f8efa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6386,3 +6386,20 @@ breakText Glyphs Glifos # 3D Box add-on 3D Box Caja 3D 3D Box Tool Herramienta Caja 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Abrir con + No importer plug-ins installed No hay plugins de importación instalados + Read files you open in its format and make documents from them Leer los archivos que abre en su formato y crear documentos a partir de ellos +@msg {_1} is an importer (File › Open With) {_1} es un plugin de importación (Archivo › Abrir con) +@msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` debe ser el id de formato de un plugin (consulte document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» abre archivos {_1}, no `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` es un {_2}, no un plugin de importación +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` es un plugin de importación: abra un archivo con document.open {{"importer"}"}} +@msg the importer returned no objects el plugin de importación no devolvió ningún objeto +@msg an importer returns `objects` and `warnings`, not `{key}` un plugin de importación devuelve `objects` y `warnings`, no `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el archivo supera los {MAX_INPUT_BYTES} bytes que lee un plugin de importación +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de importación declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f4eeaaff1..399c8e5fa 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6479,3 +6479,20 @@ breakText Glyphs Merkit # 3D Box add-on 3D Box 3D-laatikko 3D Box Tool 3D-laatikkotyökalu + +# Importer plug-ins (File › Open With, document.open) + Open With Avaa ohjelmalla + No importer plug-ins installed Tuontilaajennuksia ei ole asennettu + Read files you open in its format and make documents from them Lukea sen muodossa avaamasi tiedostot ja tehdä niistä asiakirjoja +@msg {_1} is an importer (File › Open With) {_1} on tuontilaajennus (Tiedosto › Avaa ohjelmalla) +@msg missing `importer` (see document.formats) `importer` puuttuu (katso document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` on oltava laajennuksen muototunnus (katso document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” avaa {_1}-tiedostoja, ei `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` on {_2}, ei tuontilaajennus +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` on tuontilaajennus: avaa tiedosto komennolla document.open {{"importer"}"}} +@msg the importer returned no objects tuontilaajennus ei palauttanut objekteja +@msg an importer returns `objects` and `warnings`, not `{key}` tuontilaajennus palauttaa `objects` ja `warnings`, ei `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tiedosto on suurempi kuin tuontilaajennuksen lukemat {MAX_INPUT_BYTES} tavua +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` tuontilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions` +@msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d9e77d68d..78215fec9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6439,3 +6439,20 @@ breakText Glyphs Glyphes # 3D Box add-on 3D Box Boîte 3D 3D Box Tool Outil Boîte 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Ouvrir avec + No importer plug-ins installed Aucun module externe d’importation installé + Read files you open in its format and make documents from them Lire les fichiers que vous ouvrez dans son format et en faire des documents +@msg {_1} is an importer (File › Open With) {_1} est un module externe d’importation (Fichier › Ouvrir avec) +@msg missing `importer` (see document.formats) `importer` manquant (voir document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` doit être l’identifiant de format d’un module externe (voir document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` « {shown} » ouvre les fichiers {_1}, pas `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` est un {_2}, pas un module externe d’importation +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}} +@msg the importer returned no objects le module externe d’importation n’a renvoyé aucun objet +@msg an importer returns `objects` and `warnings`, not `{key}` un module externe d’importation renvoie `objects` et `warnings`, pas `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads le fichier dépasse les {MAX_INPUT_BYTES} octets qu’un module externe d’importation lit +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’importation déclare de 1 à {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` +@msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b5791f4a4..38ac80141 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6892,7 +6892,7 @@ @msg Offset Path: the path is too complex to offset ऑफसेट पाथ: पाथ ऑफसेट करने के लिए बहुत जटिल है @msg on must be true or false on: पर सच होना चाहिए या गलत होना चाहिए @msg only a dock group above or below others collapses, not one beside another केवल दूसरों के ऊपर या नीचे एक डॉक समूह गिर जाता है, एक दूसरे के बगल में नहीं -@msg only an exporter declares `extensions` केवल निर्यातक `extensions` घोषित करता है +@msg only an exporter or an importer declares `extensions` केवल एक्सपोर्टर या इंपोर्टर `extensions` घोषित करता है @msg only an object of the Global Edit other than the one it started from can be left out केवल ग्लोबल एडिट का एक ऑब्जेक्ट, इसके अलावा इसे शुरू किया जा सकता है, छोड़ दिया जा सकता है @msg only groups, layers, type and images have a Contents (Characters) row केवल समूहों, लेयर्स, प्रकार और इमेज में एक सामग्री (Character) पंक्ति है @msg only http, https and mailto URLs open in the browser, not “{url}” केवल http, https और Mailto URL ब्राउज़र में खुलते हैं, न कि "{url}" @@ -7927,3 +7927,19 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + +# Importer plug-ins (File › Open With, document.open) + Open With इसके साथ खोलें + No importer plug-ins installed कोई इंपोर्टर प्लग-इन स्थापित नहीं है + Read files you open in its format and make documents from them उसके फ़ॉर्मेट में खोली गई फ़ाइलें पढ़ना और उनसे डॉक्यूमेंट बनाना +@msg {_1} is an importer (File › Open With) {_1} एक इंपोर्टर है (फ़ाइल › इसके साथ खोलें) +@msg missing `importer` (see document.formats) `importer` नहीं दिया गया (document.formats देखें) +@msg `importer` must be a plug-in format id (see document.formats) `importer` किसी प्लग-इन का फ़ॉर्मेट id होना चाहिए (document.formats देखें) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} फ़ाइलें खोलता है, `{_2}` नहीं +@msg `{_1}` is a {_2}, not an importer `{_1}` एक {_2} है, इंपोर्टर नहीं +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` एक इंपोर्टर है: document.open {{"importer"}"}} से फ़ाइल खोलें +@msg the importer returned no objects इंपोर्टर ने कोई ऑब्जेक्ट नहीं लौटाया +@msg an importer returns `objects` and `warnings`, not `{key}` इंपोर्टर `objects` और `warnings` लौटाता है, `{key}` नहीं +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads फ़ाइल उन {MAX_INPUT_BYTES} बाइट्स से बड़ी है जो इंपोर्टर पढ़ता है +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है +@msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 58d649442..a4404ede3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6428,3 +6428,20 @@ breakText Glyphs Glifák # 3D Box add-on 3D Box 3D doboz 3D Box Tool 3D doboz eszköz + +# Importer plug-ins (File › Open With, document.open) + Open With Megnyitás ezzel + No importer plug-ins installed Nincs telepített importáló bővítmény + Read files you open in its format and make documents from them Beolvashatja a formátumában megnyitott fájlokat, és dokumentumokat készíthet belőlük +@msg {_1} is an importer (File › Open With) importáló bővítmény: {_1} (Fájl › Megnyitás ezzel) +@msg missing `importer` (see document.formats) hiányzik az `importer` (lásd document.formats) +@msg `importer` must be a plug-in format id (see document.formats) az `importer` egy bővítmény formátumazonosítója kell legyen (lásd document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” {_1} fájlokat nyit meg, nem `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}`: {_2}, nem importáló bővítmény +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` importáló bővítmény: nyisson meg egy fájlt a document.open {{"importer"}"}} paranccsal +@msg the importer returned no objects az importáló bővítmény nem adott vissza objektumot +@msg an importer returns `objects` and `warnings`, not `{key}` az importáló bővítmény `objects` és `warnings` értéket ad vissza, nem `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads a fájl nagyobb annál a {MAX_INPUT_BYTES} bájtnál, amit egy importáló bővítmény beolvas +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` az importáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg +@msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket +@msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6211e49da..763c47a2a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6296,3 +6296,8 @@ breakText Glyphs Glif # 3D Box add-on 3D Box Kotak 3D 3D Box Tool Alat Kotak 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Buka Dengan + No importer plug-ins installed Tidak ada plug-in impor yang terinstal + Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d11fcb22d..f4bde607f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6064,3 +6064,20 @@ breakText Glyphs Glifi # 3D Box add-on 3D Box Scatola 3D 3D Box Tool Strumento Scatola 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Apri con + No importer plug-ins installed Nessun plug-in di importazione installato + Read files you open in its format and make documents from them Leggere i file che apri nel suo formato e crearne documenti +@msg {_1} is an importer (File › Open With) {_1} è un plug-in di importazione (File › Apri con) +@msg missing `importer` (see document.formats) manca `importer` (vedi document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` deve essere l’id di formato di un plug-in (vedi document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» apre file {_1}, non `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` è un {_2}, non un plug-in di importazione +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` è un plug-in di importazione: apri un file con document.open {{"importer"}"}} +@msg the importer returned no objects il plug-in di importazione non ha restituito alcun oggetto +@msg an importer returns `objects` and `warnings`, not `{key}` un plug-in di importazione restituisce `objects` e `warnings`, non `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads il file supera i {MAX_INPUT_BYTES} byte che un plug-in di importazione legge +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di importazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6bb917eec..9415aad1f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6129,3 +6129,20 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3Dボックス 3D Box Tool 3Dボックスツール + +# Importer plug-ins (File › Open With, document.open) + Open With このアプリケーションで開く + No importer plug-ins installed 読み込みプラグインはインストールされていません + Read files you open in its format and make documents from them 独自形式で開いたファイルの読み取りとドキュメントの作成 +@msg {_1} is an importer (File › Open With) {_1} は読み込みプラグインです (「ファイル › このアプリケーションで開く」から使用してください) +@msg missing `importer` (see document.formats) `importer` がありません (document.formats を参照) +@msg `importer` must be a plug-in format id (see document.formats) `importer` にはプラグインの形式 ID を指定してください (document.formats を参照) +@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」が開くのは {_1} ファイルで、`{_2}` ではありません +@msg `{_1}` is a {_2}, not an importer `{_1}` は {_2} で、読み込みプラグインではありません +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` は読み込みプラグインです: document.open {{"importer"}"}} でファイルを開いてください +@msg the importer returned no objects 読み込みプラグインがオブジェクトを返しませんでした +@msg an importer returns `objects` and `warnings`, not `{key}` 読み込みプラグインが返すのは `objects` と `warnings` で、`{key}` ではありません +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ファイルが読み込みプラグインの読み取る {MAX_INPUT_BYTES} バイトを超えています +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 読み込みプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します +@msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです +@msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 483955091..c6db1fce7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6456,3 +6456,20 @@ font-script Japanese იაპონური # 3D Box add-on 3D Box 3D ყუთი 3D Box Tool 3D ყუთის ხელსაწყო + +# Importer plug-ins (File › Open With, document.open) + Open With გახსნა პროგრამით + No importer plug-ins installed იმპორტის პლაგინები არ არის დაყენებული + Read files you open in its format and make documents from them მის ფორმატში გახსნილი ფაილების წაკითხვა და მათგან დოკუმენტების შექმნა +@msg {_1} is an importer (File › Open With) {_1} იმპორტის პლაგინია (ფაილი › გახსნა პროგრამით) +@msg missing `importer` (see document.formats) `importer` აკლია (იხ. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` უნდა იყოს პლაგინის ფორმატის ID (იხ. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ ხსნის {_1} ფაილებს და არა `{_2}`-ს +@msg `{_1}` is a {_2}, not an importer `{_1}` არის {_2} და არა იმპორტის პლაგინი +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` იმპორტის პლაგინია: გახსენით ფაილი document.open {{"importer"}"}}-ით +@msg the importer returned no objects იმპორტის პლაგინმა ობიექტები არ დააბრუნა +@msg an importer returns `objects` and `warnings`, not `{key}` იმპორტის პლაგინი აბრუნებს `objects`-ს და `warnings`-ს და არა `{key}`-ს +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ფაილი აღემატება {MAX_INPUT_BYTES} ბაიტს, რომელსაც იმპორტის პლაგინი კითხულობს +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` იმპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს +@msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს +@msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9a6a465c9..090252435 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6025,3 +6025,20 @@ breakText Glyphs 글리프 # 3D Box add-on 3D Box 3D 상자 3D Box Tool 3D 상자 도구 + +# Importer plug-ins (File › Open With, document.open) + Open With 연결 프로그램 + No importer plug-ins installed 설치된 가져오기 플러그인 없음 + Read files you open in its format and make documents from them 자체 형식으로 연 파일을 읽고 문서로 만들기 +@msg {_1} is an importer (File › Open With) {_1}은(는) 가져오기 플러그인입니다(파일 › 연결 프로그램) +@msg missing `importer` (see document.formats) `importer`가 없습니다(document.formats 참조) +@msg `importer` must be a plug-in format id (see document.formats) `importer`는 플러그인의 형식 ID여야 합니다(document.formats 참조) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}”은(는) {_1} 파일을 엽니다. `{_2}`은(는) 열지 않습니다 +@msg `{_1}` is a {_2}, not an importer `{_1}`은(는) {_2}이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}`은(는) 가져오기 플러그인입니다: document.open {{"importer"}"}}로 파일을 여세요 +@msg the importer returned no objects 가져오기 플러그인이 개체를 반환하지 않았습니다 +@msg an importer returns `objects` and `warnings`, not `{key}` 가져오기 플러그인은 `{key}`가 아닌 `objects`와 `warnings`를 반환합니다 +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 파일이 가져오기 플러그인이 읽는 {MAX_INPUT_BYTES}바이트보다 큽니다 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 가져오기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 +@msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다 +@msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0328f1fc5..875bc733c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6342,3 +6342,8 @@ breakText Glyphs Glyphs # 3D Box add-on 3D Box 3D-doos 3D Box Tool 3D-doosgereedschap + +# Importer plug-ins (File › Open With, document.open) + Open With Openen met + No importer plug-ins installed Geen importplug-ins geïnstalleerd + Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f98e35520..37ad72246 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6299,3 +6299,20 @@ breakText Glyphs Glify # 3D Box add-on 3D Box Bryła 3D 3D Box Tool Narzędzie Bryła 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Otwórz za pomocą + No importer plug-ins installed Brak zainstalowanych wtyczek importu + Read files you open in its format and make documents from them Odczytywać pliki otwierane w jego formacie i tworzyć z nich dokumenty +@msg {_1} is an importer (File › Open With) {_1} to wtyczka importu (Plik › Otwórz za pomocą) +@msg missing `importer` (see document.formats) brak `importer` (zobacz document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` musi być identyfikatorem formatu wtyczki (zobacz document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” otwiera pliki {_1}, a nie `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` to {_2}, a nie wtyczka importu +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` to wtyczka importu: otwórz plik za pomocą document.open {{"importer"}"}} +@msg the importer returned no objects wtyczka importu nie zwróciła żadnych obiektów +@msg an importer returns `objects` and `warnings`, not `{key}` wtyczka importu zwraca `objects` i `warnings`, a nie `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads plik jest większy niż {MAX_INPUT_BYTES} bajtów, które odczytuje wtyczka importu +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka importu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu +@msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce6919f8d..8510a00c7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6517,3 +6517,20 @@ swatch Yellow Green Verde-amarelado # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Ferramenta Caixa 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Abrir Com + No importer plug-ins installed Nenhum plug-in de importação instalado + Read files you open in its format and make documents from them Ler os arquivos que você abre no formato dele e criar documentos a partir deles +@msg {_1} is an importer (File › Open With) {_1} é um plug-in de importação (Arquivo › Abrir Com) +@msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` deve ser o id de formato de um plug-in (consulte document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” abre arquivos {_1}, não `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` é um {_2}, não um plug-in de importação +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` é um plug-in de importação: abra um arquivo com document.open {{"importer"}"}} +@msg the importer returned no objects o plug-in de importação não retornou nenhum objeto +@msg an importer returns `objects` and `warnings`, not `{key}` um plug-in de importação retorna `objects` e `warnings`, não `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads o arquivo é maior que os {MAX_INPUT_BYTES} bytes que um plug-in de importação lê +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de importação declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a1ae4746c..4c6aa7564 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6533,3 +6533,20 @@ shape Open Открытый # 3D Box add-on 3D Box 3D-параллелепипед 3D Box Tool Инструмент «3D-параллелепипед» + +# Importer plug-ins (File › Open With, document.open) + Open With Открыть с помощью + No importer plug-ins installed Модули импорта не установлены + Read files you open in its format and make documents from them Читать файлы, открываемые в его формате, и создавать из них документы +@msg {_1} is an importer (File › Open With) {_1} — модуль импорта (Файл › Открыть с помощью) +@msg missing `importer` (see document.formats) не указан `importer` (см. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` должен быть идентификатором формата модуля (см. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» открывает файлы {_1}, а не `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` — это {_2}, а не модуль импорта +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` — модуль импорта: откройте файл с помощью document.open {{"importer"}"}} +@msg the importer returned no objects модуль импорта не вернул ни одного объекта +@msg an importer returns `objects` and `warnings`, not `{key}` модуль импорта возвращает `objects` и `warnings`, а не `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл больше {MAX_INPUT_BYTES} байт, которые читает модуль импорта +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` модуль импорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта +@msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b13141b8e..06d3ea547 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -7481,7 +7481,7 @@ digits Hindi Hindi @msg offset out of range förskjutningen ligger utanför intervallet @msg Offset Path: {e} Förskjut bana: {e} @msg Offset Path: the path is too complex to offset Förskjut bana: banan är för komplex för att förskjutas -@msg only an exporter declares `extensions` endast en exportfunktion deklarerar `extensions` +@msg only an exporter or an importer declares `extensions` endast en exportfunktion eller importfunktion deklarerar `extensions` @msg only groups, layers, type and images have a Contents (Characters) row endast grupper, lager, text och bilder har en rad för Innehåll (Tecken) @msg only interior mesh lines can be deleted endast inre nätlinjer kan raderas @msg only solid colours can be added (not gradients or patterns) endast enhetliga färger kan läggas till (inte gradienter eller mönster) @@ -7932,3 +7932,19 @@ digits Hindi Hindi @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) @msg zoom must be positive zoom måste vara positiv + +# Importer plug-ins (File › Open With, document.open) + Open With Öppna med + No importer plug-ins installed Inga importplugins är installerade + Read files you open in its format and make documents from them Läsa filer som du öppnar i dess format och skapa dokument av dem +@msg {_1} is an importer (File › Open With) {_1} är ett importinsticksprogram (Fil › Öppna med) +@msg missing `importer` (see document.formats) `importer` saknas (se document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` måste vara ett formatid för ett plugin (se document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” öppnar {_1}-filer, inte `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` är av typen {_2}, inte en importfunktion +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` är en importfunktion: öppna en fil med document.open {{"importer"}"}} +@msg the importer returned no objects importfunktionen returnerade inga objekt +@msg an importer returns `objects` and `warnings`, not `{key}` en importfunktion returnerar `objects` och `warnings`, inte `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads filen är större än de {MAX_INPUT_BYTES} byte som en importfunktion läser +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions` +@msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d3fc04823..995ed31ca 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6401,3 +6401,20 @@ breakText Glyphs Glifler # 3D Box add-on 3D Box 3B Kutu 3D Box Tool 3B Kutu Aracı + +# Importer plug-ins (File › Open With, document.open) + Open With Birlikte Aç + No importer plug-ins installed İçe aktarma eklentisi yüklü değil + Read files you open in its format and make documents from them Kendi biçiminde açtığınız dosyaları okuma ve bunlardan belge oluşturma +@msg {_1} is an importer (File › Open With) {_1} bir içe aktarma eklentisidir (Dosya › Birlikte Aç) +@msg missing `importer` (see document.formats) `importer` eksik (bkz. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` bir eklentinin biçim kimliği olmalıdır (bkz. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} dosyalarını açar, `{_2}` dosyasını değil +@msg `{_1}` is a {_2}, not an importer `{_1}` bir {_2}, içe aktarma eklentisi değil +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` bir içe aktarma eklentisidir: dosyayı document.open {{"importer"}"}} ile açın +@msg the importer returned no objects içe aktarma eklentisi hiç nesne döndürmedi +@msg an importer returns `objects` and `warnings`, not `{key}` içe aktarma eklentisi `{key}` değil, `objects` ve `warnings` döndürür +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads dosya, içe aktarma eklentisinin okuduğu {MAX_INPUT_BYTES} bayttan büyük +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` içe aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir +@msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir +@msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c256b0064..5044fe895 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6044,3 +6044,20 @@ breakText Glyphs Гліфи # 3D Box add-on 3D Box 3D-паралелепіпед 3D Box Tool Інструмент «3D-паралелепіпед» + +# Importer plug-ins (File › Open With, document.open) + Open With Відкрити за допомогою + No importer plug-ins installed Модулі імпорту не встановлено + Read files you open in its format and make documents from them Читати файли, які ви відкриваєте в його форматі, і створювати з них документи +@msg {_1} is an importer (File › Open With) {_1} - модуль імпорту (Файл › Відкрити за допомогою) +@msg missing `importer` (see document.formats) не вказано `importer` (див. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` має бути ідентифікатором формату модуля (див. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» відкриває файли {_1}, а не `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` - це {_2}, а не модуль імпорту +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` - модуль імпорту: відкрийте файл за допомогою document.open {{"importer"}"}} +@msg the importer returned no objects модуль імпорту не повернув жодного об’єкта +@msg an importer returns `objects` and `warnings`, not `{key}` модуль імпорту повертає `objects` і `warnings`, а не `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл більший за {MAX_INPUT_BYTES} байтів, які читає модуль імпорту +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` модуль імпорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту +@msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 887d9a0f6..758e6799d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6045,3 +6045,20 @@ breakText Glyphs Glyph # 3D Box add-on 3D Box Hộp 3D 3D Box Tool Công cụ Hộp 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Mở bằng + No importer plug-ins installed Không có plug-in nhập được cài đặt + Read files you open in its format and make documents from them Đọc các tệp bạn mở theo định dạng của nó và tạo tài liệu từ chúng +@msg {_1} is an importer (File › Open With) {_1} là plug-in nhập (Tệp › Mở bằng) +@msg missing `importer` (see document.formats) thiếu `importer` (xem document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` phải là id định dạng của một plug-in (xem document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” mở tệp {_1}, không phải `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` là {_2}, không phải plug-in nhập +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` là plug-in nhập: hãy mở tệp bằng document.open {{"importer"}"}} +@msg the importer returned no objects plug-in nhập không trả về đối tượng nào +@msg an importer returns `objects` and `warnings`, not `{key}` plug-in nhập trả về `objects` và `warnings`, không phải `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tệp lớn hơn {MAX_INPUT_BYTES} byte mà plug-in nhập đọc +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` plug-in nhập khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions` +@msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d99db0948..a43d1a88b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6285,3 +6285,20 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Importer plug-ins (File › Open With, document.open) + Open With 打开方式 + No importer plug-ins installed 未安装导入增效工具 + Read files you open in its format and make documents from them 读取以其格式打开的文件并据此创建文档 +@msg {_1} is an importer (File › Open With) {_1} 是导入增效工具 (文件 › 打开方式) +@msg missing `importer` (see document.formats) 缺少 `importer` (参见 document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` 必须是增效工具的格式 ID (参见 document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” 打开的是 {_1} 文件,不是 `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是导入增效工具 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是导入增效工具:请用 document.open {{"importer"}"}} 打开文件 +@msg the importer returned no objects 导入增效工具未返回任何对象 +@msg an importer returns `objects` and `warnings`, not `{key}` 导入增效工具返回 `objects` 和 `warnings`,不是 `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 文件超过导入增效工具读取的 {MAX_INPUT_BYTES} 字节 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 导入增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` +@msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions` +@msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb6b40a80..863bd31ba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6082,3 +6082,20 @@ breakText Glyphs 字符 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Importer plug-ins (File › Open With, document.open) + Open With 開啟方式 + No importer plug-ins installed 未安裝匯入增效模組 + Read files you open in its format and make documents from them 讀取以其格式開啟的檔案並據此建立文件 +@msg {_1} is an importer (File › Open With) {_1} 是匯入增效模組 (檔案 › 開啟方式) +@msg missing `importer` (see document.formats) 未提供 `importer` (請參閱 document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` 必須是增效模組的格式 ID (請參閱 document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」開啟的是 {_1} 檔案,不是 `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是匯入增效模組 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是匯入增效模組:請用 document.open {{"importer"}"}} 開啟檔案 +@msg the importer returned no objects 匯入增效模組未傳回任何物件 +@msg an importer returns `objects` and `warnings`, not `{key}` 匯入增效模組傳回 `objects` 和 `warnings`,不是 `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 檔案超過匯入增效模組讀取的 {MAX_INPUT_BYTES} 位元組 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 匯入增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` +@msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions` +@msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget` diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1e34e38b..41694fdbe 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -24,7 +24,7 @@ pub(crate) use export::{ pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, - open_path, open_reporting, opens_as, place_bytes, recent_files, report_open_error, + open_path, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error, }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 85ce45c40..f5bb617df 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -174,6 +174,7 @@ fn permission_words(name: &str) -> String { Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), + Some(permissions::Permission::ImportRead) => tl!("Read files you open in its format and make documents from them"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() @@ -256,6 +257,70 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { open_reporting(app, &path).map(|_| ()) } +/// File › Open With: opens the file `p` names (`path`), else one picked among the files with the +/// extensions the importer plug-in `importer` lists, with that importer, also when a built-in +/// format reads the extension (`document.open {importer}`); a failure is reported as File › Open +/// reports it. The web's file picker can't carry the importer: there File › Open's picker opens, +/// and the file opens as File › Open opens it. +pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { + let importer = p.get("importer").and_then(Value::as_str).ok_or("missing `importer` (see document.formats)")?; + let plugin = vectorcraft_plugins::import::named(importer).map_err(|e| e.to_string())?; + let path = match p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()) { + Some(path) => path.to_string(), + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + // The importer's format: its name and extensions, made once for each installation. + let filters = fileio::format(&vectorcraft_plugins::export::format_id(plugin.id())) + .filter(|f| f.read) + .map(|f| FilePick::filter(f.label, f.extensions)) + .into_iter() + .collect(); + pick_open(app, &FilePick { filters, folder: open_dialog_folder(app), ..Default::default() })? + } + }; + let opened = vectorcraft_engine::file_access::check_read(&path) + .and_then(|()| check_import_size(&path, true)) + .and_then(|()| read(app, &path)) + .and_then(|bytes| { + open_document(app, &path, &bytes, Some(path.clone()), &json!({ "importer": vectorcraft_plugins::export::format_id(plugin.id()) })) + }); + if let Err(e) = &opened { + report_open_error(app, &fileio::file_name(&path), e); + } + opened +} + +/// A file at `path` that only an importer plug-in reads (with `explicit`, the one named for it), +/// larger than the importer's input limit: an error before the file is read. A file whose start +/// shows a built-in format passes when no importer was named, since a built-in reader opens it. +#[cfg(not(target_arch = "wasm32"))] +fn check_import_size(path: &str, explicit: bool) -> Result<(), String> { + use std::io::Read as _; + use vectorcraft_plugins::import; + let ext = fileio::extension(path); + if !explicit && (fileio::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { + return Ok(()); + } + if std::fs::metadata(path).map_or(true, |m| m.len() <= import::MAX_INPUT_BYTES as u64) { + return Ok(()); + } + if !explicit { + let mut head = Vec::new(); + if let Ok(f) = std::fs::File::open(path) { + let _ = f.take(64 << 10).read_to_end(&mut head); + } + if fileio::detect(path, &head).is_some() { + return Ok(()); + } + } + Err(format!("can't open `{}`: {}", fileio::file_name(path), import::too_large())) +} + +#[cfg(target_arch = "wasm32")] +fn check_import_size(_: &str, _: bool) -> Result<(), String> { + Ok(()) +} + /// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) /// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { @@ -353,6 +418,8 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) note_recent(app, path); return Ok(Value::Null); } + // A file only an importer plug-in reads is not read past the importer's input limit. + check_import_size(path, false)?; let mut bytes = read(app, path)?; if background && opens_as(path).is_none() { // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index b8d314346..d2f460834 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -138,16 +138,17 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { ]; SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom") + || matches!(id, "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom") || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) } -/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. +/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter, an +/// Effect › Plug-ins effect or a File › Open With importer), by the plug-in's own name? Told by the +/// item itself, so it holds while another thread installs or removes plug-ins. pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() + matches!(id, "plugin.dialog" | "file.openWith") + || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 8ccb80c04..8031a98da 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -110,6 +110,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_path(app, &path), None => io::open_dialog(app).map(|_| Value::Null), }, + "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), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 3a55ab37c..46d695f16 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -28,6 +28,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("New…", "file.newDialog"), c("New from Template…", "file.newFromTemplate"), c("Open…", "file.open"), + sub("Open With", crate::dialogs::plugin::open_with_menu()), sub("Open Recent Files", { let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); v.push(Sep); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f4b29416d..5505556d1 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -30,6 +30,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+O", "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", ), + ( + "file.openWith", + "Open With", + "", + "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → document.open's result | null while the picker is open", + ), ( "file.save", "Save", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 92314bfba..28a4d9d11 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -68,6 +68,7 @@ pub(crate) enum Entry { /// document active at the time has closed meanwhile. const DOCUMENT_FREE: &[&str] = &[ "file.open", + "file.openWith", "file.newFromTemplate", "ui.installPlugin", "ui.checkScript", diff --git a/docs/gaps.md b/docs/gaps.md index 6780782ab..e2c7bfed7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -739,9 +739,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G17 Ecosystem: third-party plug-ins - **Missing:** Illustrator's ecosystem is a C++ plug-in SDK and a large commercial plug-in market. VectorCraft - has sandboxed WebAssembly plug-ins for object filters, live effects and file formats it writes - ([plugins.md](plugins.md)) and MCP; missing are tool plug-ins, panel plug-ins, importers, a plug-in directory and + has sandboxed WebAssembly plug-ins for object filters, live effects and file formats it writes or reads + ([plugins.md](plugins.md)) and MCP; missing are tool plug-ins, panel plug-ins, a plug-in directory and any third-party plug-ins (#710). +- **Done:** importer plug-ins (`"kind": "importer"`). A plug-in adds a read-only file format that File › Open lists + and File › Open With names; `document.open` uses it for a file no built-in format reads, or when `importer` names it. + It is given at most 128 MiB of the file and returns paths and compound paths, checked as a filter's output is, which + become a new document; nothing opens when it fails, declines or is cancelled. Engine, unit and headless Open With + regressions. - **Done:** exporter plug-ins (`"kind": "exporter"`). A plug-in adds a write-only file format that File › Export › Export As lists after the built-in ones and `document.export` writes (`plugin.`, or a path ending in one of its extensions). It is given the document as the native file's JSON and its parameters, with a larger budget than a diff --git a/docs/mcp.md b/docs/mcp.md index b65179035..ac4ea9cb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -437,7 +437,7 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -5985,8 +5985,17 @@ undo step; it may change, delete or add objects, and the result is selected). A id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions -that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes -no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its +that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` +with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: +"plugin.", params?}` (and `open_file`) opens a file whose extension no built-in format reads, by its content or its +extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). +`importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such +as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is +their bounds, Save then asks for a name, and `warnings` holds the importer's warnings. `plugin.run` of an importer is +an error that names `document.open`. In the app, File › Open With lists the installed importers, and +`file.openWith {importer, path?}` opens `path`, or a file picked among the importer's extensions, with one (on the web +it opens File › Open's picker). A failing plug-in returns an error and leaves the document as it was (an export writes +no file, and an import opens no document). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) @@ -6004,6 +6013,7 @@ value or the folder the preference already holds, and the web build does not che {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/plot.hpgl","importer":"plugin.org.example.hpgl"}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..e92a5a11e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -5,7 +5,7 @@ arbitrary machine code and cannot work in the browser build. Instead, plug-ins a run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. -There are three kinds of plug-in: +There are four kinds of plug-in: - **Object filters** read the selected paths and compound paths (geometry, fills, strokes, gradients, opacity) and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path @@ -15,6 +15,9 @@ There are three kinds of plug-in: - **Exporters** write the whole document in a file format of their own, for pen plotters, cutting machines, laser cutters and the like. File › Export › Export As lists them after the built-in formats, and the host writes the bytes they return: a plug-in still never touches a file. +- **Importers** read files in a format of their own, such as a plotter's or a cutting machine's, into a new document of + paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host + hands them the file's bytes. This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). @@ -38,6 +41,16 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li `document.export {"format": "plugin.", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. +An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id, its extensions +in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format +reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the +one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, +which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` +only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an +importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high); +the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are +VectorCraft's own. + In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text, lists @@ -45,7 +58,10 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs +same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › +Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the +installed importers by name (`file.openWith {"importer", "path"?}`), and each opens a file picked among its extensions +with that importer (on the web, File › Open's picker opens instead). **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). @@ -71,20 +87,22 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | -| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export 10 G + 4,000 per byte | `Limit("instruction budget")` | -| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run or export (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that | | Call depth | 1,024 frames | trap | | Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | An export runs where the export runs: with Background Export on, on the export's worker, whose Cancel stops the -plug-in between two of those 20 M-instruction slices; nothing is written then. +plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the +open it runs in is cancelled, and nothing opens then. ### Heavy filters Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its -manifest (only a filter may: a live effect runs whenever the document is drawn, and an exporter has an export's -budget). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s +manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how much there is only once the run has ended. @@ -115,12 +133,14 @@ capability model proposed for PhotoCraft plug-ins | `state` | filter | Keep data of its own with the document (`documentData`). | | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | +| `import.read` | importer | Read files you open in its format and make documents from them. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and - `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is - empty may do none of these, and also installs without a prompt. -- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + `export.write`; an importer `import.read`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. + A filter whose list is empty may do none of these, and also installs without a prompt. +- An effect, an exporter or an importer that lists permissions lists all of its implied ones. A filter may list any of + its three. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. @@ -197,16 +217,17 @@ pointers are offsets into the module's memory. - `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module with the same id replaces the old one. A live effect's effect id is `plugin.`. -- `name`: the menu label, or an exporter's format name in Export As (1–64 printable characters). `kind`: `"filter"` - (object filter), `"effect"` (live effect) or `"exporter"`. Exporters came after the first ABI v1 hosts, which refuse - the kind when the plug-in is installed. +- `name`: the menu label, or an exporter's or importer's format name in Export As, Open and Open With (1–64 printable + characters). `kind`: `"filter"` (object filter), `"effect"` (live effect), `"exporter"` or `"importer"`. Exporters + and importers came after the first ABI v1 hosts, which refuse the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. - `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names ([permissions](#permissions)); without it, what its kind implies. -- `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot - (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): - `document.export` then writes the exporter's format only when asked for by its format id. +- `extensions` (exporters and importers only, and required): 1–8 file extensions, lower-case letters and digits + without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both + list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id, and + `document.open` reads a file with the importer only when `importer` names it. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults @@ -255,6 +276,8 @@ appearance and effect record, artboard, swatch and symbol, so an exporter reads `version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. + ```json {"objects": [ {"id": 12, "type": "path", "name": "Leaf", @@ -299,14 +322,16 @@ type and `blob` but without their data, which follows the JSON in a saved file; "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}} ``` -`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"` or `"import"`; `bounds` the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. +extension (the document's name when the export is not given a path), which some formats keep in their header. An +import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being +opened and its name without the extension. ### Output @@ -357,6 +382,11 @@ An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file, is, and what the export left out or changed, for the user (at most 100 warnings of at most 500 characters each, shown with the export's own). A decline (`{"error": …}`), a missing `dataBase64` or data that isn't base64 writes nothing. +An **importer** returns `{"objects": [...], "warnings"?: ["…"]}`: new paths and compound paths (no `id`) in the +filters' object form, checked as a filter's new objects are, with warnings capped as an exporter's are and shown with +the open's own. An output with no objects, with keys other than `objects`, `warnings` and `error`, or with an object +that fails the checks opens nothing, and neither does a decline (`{"error": …}`). + ## Writing a plug-in in Rust [`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every @@ -386,8 +416,8 @@ per change of their input, parameters or paints (results are cached), not per fr ## Not in v1 - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host - callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open - files). + callbacks (a plug-in reporting progress or asking whether it was cancelled), and importers that place files + (File › Place reads only the built-in formats) or make type, images, layers or artboards. - Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 88a2a8707..36fca35a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1439,6 +1439,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged. +- **Importer plug-ins (G17):** a WebAssembly plug-in of kind `importer` adds a read-only file format, as [the help page](https://ai-sdk-2020.84.nz/html/struct_a_i_file_format_suite) describes for file format plug-ins that read: File › Open lists its files under "All readable files" and its own filter, and File › Open With lists each importer by name. `document.open` uses it for a file whose extension no built-in format reads, by content or extension (the importer whose id sorts first, when several list the extension), or the one `importer` names, also for an extension a built-in format reads; a damaged file of a built-in format reports that format's error. The plug-in is given at most 128 MiB of the file and `_context` (`mode: "import"`, `extension`, `name`), and returns paths and compound paths in the filters' object JSON, checked as a filter's output is; they become a new document whose artboard is their bounds, which Save asks a name for. A decline, invalid output, a trap or Cancel opens nothing. The routing rule, the limit and Open With are VectorCraft's own. Regressions: `crates/engine/tests/plugin_import.rs`, `crates/plugins/src/import.rs`, `crates/ui-egui/src/dialogs/tests_plugin_import.rs`. Scores and hours unchanged. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged.