diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 13ad41667..9016a5f53 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). //! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], @@ -31,6 +33,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +mod importer; mod legacy_ai; mod load; mod mesh3d; @@ -135,9 +138,10 @@ pub(crate) use export_names::{ 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, + DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, + export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, + open_filters, opens_as_other, opens_with_importer, 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 479877992..caa08cfda 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -728,12 +728,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 12495a95f..52478ac13 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 ef87ea1b6..dfe9c911e 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -659,11 +659,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`]). @@ -676,18 +677,110 @@ fn format_filters() -> impl Iterator bool { + let ext = extension(name); + !ext.is_empty() && !PLACE_EXTS.contains(&ext.as_str()) && !vectorcraft_plugins::import::for_extension(&ext).is_empty() +} + +/// Whether File › Open opens a file of extension `ext` (lower case) as something other than a +/// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print, +/// perspective grid or export presets or composite fonts (imported), a Libraries panel library, or a +/// swatch or graphic style library. +pub fn opens_as_other(ext: &str) -> bool { + [ + crate::cmd::plugin::EXTS, + crate::cmd::shortcutset::EXTS, + crate::cmd::scripts::EXTS, + crate::cmd::flatten::PRESET_EXTS, + crate::cmd::pdfcmds::PRESET_EXTS, + crate::cmd::printpresets::PRESET_EXTS, + crate::cmd::perspgrid::PRESET_EXTS, + screens::PRESET_FILE_EXTS, + crate::cmd::compositefonts::EXTS, + crate::cmd::library::LIBRARY_EXTS, + crate::cmd::swatchlib::LIBRARY_EXTS, + std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT), + ] + .iter() + .any(|exts| exts.contains(&ext)) +} + +/// File › Open's extensions and filters for the installed importer plug-ins. +type ImporterPart = (&'static [&'static str], Vec<(&'static str, &'static [&'static str])>); + +/// File › Open's part for the installed importer plug-ins: the extensions of "All readable files" +/// ([`OPEN_EXTS`], then the importers' extensions in plug-in id order) and one filter per importer +/// that has extensions left, under its name. An importer's extensions leave out those a built-in +/// format reads, which File › Open reads with that format, and those File › Open opens as something +/// else ([`opens_as_other`]). Made once for each set of installed plug-ins, so it lives as long as +/// the dialogs that hold it. +fn importer_open_part() -> ImporterPart { + 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, part)) = made.as_ref() + && *r == revision + { + return part.clone(); + } + let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec(); + let mut filters = Vec::new(); + for f in importer_formats() { + let own: Vec<&'static str> = f.extensions.iter().copied().filter(|e| readable_format(e).is_none() && !opens_as_other(e)).collect(); + if own.is_empty() { + continue; + } + for &e in &own { + if !exts.contains(&e) { + exts.push(e); + } + } + let own: &'static [&'static str] = if own.len() == f.extensions.len() { f.extensions } else { Box::leak(own.into_boxed_slice()) }; + filters.push((f.label, own)); + } + let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) }; + let part = (exts, filters); + *made = Some((revision, part.clone())); + part +} + +/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the +/// installed importer plug-ins list that no built-in format reads and that File › Open doesn't open +/// as something else ([`opens_as_other`]), in plug-in id order. +pub fn open_extensions() -> &'static [&'static str] { + importer_open_part().0 +} + +/// File › Open's filters: "All readable files" first, then one per readable format, then one per +/// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), 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)) - .chain(format_filters()) - .chain(std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS))) + let (all, importers) = importer_open_part(); + std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters()) +} + +/// [`open_filters`] without the importer plug-ins: "All readable files" lists [`OPEN_EXTS`]. For +/// open dialogs whose files the built-in readers read (New from Template, Load Styles, a document +/// picked for its swatches, symbols, brushes or graphic styles). +pub fn builtin_open_filters() -> impl Iterator { + std::iter::once(("All readable files", OPEN_EXTS)).chain(format_filters()).chain(other_open_filters()) +} + +/// The open-dialog filters after the document formats: swatch libraries, Libraries panel +/// libraries, presets, composite fonts, shortcut sets, scripts and plug-ins. +fn other_open_filters() -> impl Iterator { + 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))) .chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS))) .chain(std::iter::once(("Print presets", crate::cmd::printpresets::PRESET_EXTS))) .chain(std::iter::once(("Export presets", screens::PRESET_FILE_EXTS))) + .chain(std::iter::once(("Composite fonts", crate::cmd::compositefonts::EXTS))) .chain(std::iter::once(("Shortcut sets", crate::cmd::shortcutset::EXTS))) .chain(std::iter::once(("Scripts", crate::cmd::scripts::EXTS))) .chain(std::iter::once(("Plug-ins", crate::cmd::plugin::EXTS))) @@ -696,12 +789,13 @@ pub fn open_filters() -> impl Iterator impl Iterator { - std::iter::once((library_label, library_exts)).chain(open_filters()) + std::iter::once((library_label, library_exts)).chain(builtin_open_filters()) } /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, @@ -715,26 +809,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; @@ -746,8 +854,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: &[], })); @@ -757,6 +865,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() @@ -845,7 +958,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..3ba6c6fa1 --- /dev/null +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -0,0 +1,170 @@ +//! `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, MAX_COORD}; +use vectorcraft_geom::Rect; +use vectorcraft_plugins::{Plugin, export, import, objects}; + +use super::super::{Result, bad, str_param}; +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()) +} + +/// How much of a file [`detect`] is given: more than its content checks read. +const SNIFF_BYTES: usize = 64 << 10; + +/// Refuses `document.open`'s `dataBase64` in `p` before it is decoded when it holds a file past +/// [`import::MAX_INPUT_BYTES`] that only an importer reads, by the rule of `read_path`. +pub(super) fn check_data(p: &Value) -> Result<()> { + let Some(b64) = str_param(p, "dataBase64") else { + return Ok(()); + }; + let name = str_param(p, "name").unwrap_or("Untitled"); + let ext = extension(name); + let explicit = named(p)?.is_some(); + let built_in = !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()); + if built_in || vectorcraft_format::base64_decoded_len(b64) <= import::MAX_INPUT_BYTES as u64 { + return Ok(()); + } + if !explicit { + let start: Vec = b64.bytes().filter(|b| !b.is_ascii_whitespace()).take(SNIFF_BYTES / 3 * 4).collect(); + let head = std::str::from_utf8(&start).ok().and_then(vectorcraft_format::base64_decode); + if head.is_some_and(|head| detect(name, &head).is_some()) { + return Ok(()); + } + } + Err(err(format!("can't open `{}`: {}", file_name(name), import::too_large()))) +} + +/// 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. A file past +/// the limit that isn't read whole is reported from its size, without reading it. +#[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, SNIFF_BYTES)?; + if head.len() < SNIFF_BYTES { + return Ok(head); + } + if !explicit && detect(path, &head).is_some() { + return super::read_file(path); + } + let too_large = || err(format!("can't open `{}`: {}", file_name(path), import::too_large())); + let size = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); + if size > import::MAX_INPUT_BYTES as u64 { + return Err(too_large()); + } + // The file can grow after the size is read. + let file = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?; + if file.len() > import::MAX_INPUT_BYTES { + return Err(too_large()); + } + Ok(file) +} + +#[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 = artboard_rect(bounds); + } + doc.title = file_name(name); + Ok(Loaded { + doc, + format, + warnings: imported.warnings, + restored: false, + converted: false, + preview_only: false, + editing_data: false, + native_pdf_compatible: None, + }) +} + +/// The artboard of art with bounds `r`: `r` within ±[`MAX_COORD`], at least 1 pt wide and high +/// about its center (the artboard of a single line or point). A side that would pass the edge +/// moves inward, so art at the edge keeps an artboard the document's edits accept. +fn artboard_rect(r: Rect) -> Rect { + let side = |lo: f64, hi: f64| { + let (lo, hi) = (lo.max(-MAX_COORD), hi.min(MAX_COORD)); + if hi - lo >= 1.0 { + return (lo, hi); + } + let mid = ((lo + hi) / 2.0).clamp(-MAX_COORD + 0.5, MAX_COORD - 0.5); + (mid - 0.5, mid + 0.5) + }; + let ((x0, x1), (y0, y1)) = (side(r.x0, r.x1), side(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 697f3c1d7..5d22630e5 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -567,10 +567,15 @@ impl ReadFile { /// [`open_read`] opens. Type in the file's composite fonts is measured again once the document /// opens and its composite fonts are registered. 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, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) @@ -681,7 +686,14 @@ 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 => { + super::importer::check_data(p)?; + 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/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 84513cd2c..c523284ba 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -377,8 +377,8 @@ fn open_exts_cover_every_readable_format() { assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); assert_eq!( filters.len(), - 10 + FORMATS.iter().filter(|f| f.read).count(), - "and swatch libraries, libraries, flattener, PDF, print and export presets, shortcut sets, scripts, plug-ins" + 11 + FORMATS.iter().filter(|f| f.read).count(), + "and swatch libraries, libraries, flattener, PDF, print and export presets, composite fonts, shortcut sets, scripts, plug-ins" ); assert!(filters.contains(&("Export presets", SCREEN_EXPORT_PRESET_EXTS)), "File › Open imports export presets"); assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts"); @@ -891,3 +891,26 @@ fn a_photo_places_the_way_its_exif_orientation_says() { let red = |p: &image::Rgb| p[0] > 150 && p[1] < 80; assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } + +/// With `importer` named, a file up to the importer's limit is read whole and a larger one is an +/// error, whatever its content. +#[test] +fn read_path_for_a_named_importer_stops_at_the_limit() { + let limit = vectorcraft_plugins::import::MAX_INPUT_BYTES as u64; + let dir = tmp_dir("read-path-limit"); + let path = dir.join("drawing.tuv"); + let name = path.to_string_lossy().into_owned(); + let named = json!({"importer": "plugin.test.notinstalled"}); + let sized = |len: u64| { + std::fs::write(&path, b"\x89PNG\r\n\x1a\n").unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(len).unwrap(); + }; + for len in [8, 64 << 10, 70_000, limit] { + sized(len); + assert_eq!(importer::read_path(&name, &named).unwrap().len() as u64, len); + } + sized(limit + 1); + let e = importer::read_path(&name, &named).unwrap_err(); + assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index c802d0e3e..03c9f97ab 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}`), @@ -65,7 +66,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); } @@ -158,6 +159,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)), + )); + } } Self::prepare_with(s, plugin, None, C, p) } @@ -446,8 +453,13 @@ pub(crate) fn run_command(s: &mut Session, id: &str, 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) { @@ -460,7 +472,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) @@ -876,7 +888,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; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {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; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 94b702ca8..e978090a7 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -15,7 +15,7 @@ use proptest::prelude::*; use serde_json::{Map, Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::wat as tpl; -use vectorcraft_plugins::{Error, Limits, Plugin}; +use vectorcraft_plugins::{Error, Kind, Limits, Plugin}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::rich_session; @@ -67,6 +67,29 @@ fn arb_junk() -> impl Strategy { }) } +/// One entry of a manifest's `extensions`: valid, dotted, mixed-case, empty or too long, or junk. +fn arb_extension() -> impl Strategy { + prop_oneof![ + 4 => "[a-z0-9]{1,16}".prop_map(Value::from), + 1 => prop::sample::select(vec![".fz", "FZ", "fZ", "a b", "", "x.y", "é", "a\u{202E}", "abcdefghijklmnopq"]).prop_map(Value::from), + 1 => "[a-z0-9]{17,32}".prop_map(Value::from), + 1 => arb_junk(), + ] +} + +/// A manifest's `extensions`: `None` leaves the key out; otherwise an empty list, up to the eight +/// allowed, too many, one repeated, or junk. +fn arb_extensions() -> impl Strategy> { + prop_oneof![ + 1 => Just(None), + 1 => Just(Some(json!([]))), + 4 => prop::collection::vec(arb_extension(), 1..=8).prop_map(|l| Some(Value::Array(l))), + 1 => prop::collection::vec(arb_extension(), 9..=12).prop_map(|l| Some(Value::Array(l))), + 1 => "[a-z0-9]{1,8}".prop_map(|e| Some(json!([e, "fz", e]))), + 1 => arb_junk().prop_map(Some), + ] +} + /// Numbers that tend to break arithmetic. fn arb_num() -> impl Strategy { prop_oneof![ @@ -512,6 +535,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 bulk_memory_plugins_never_crash(case in arb_bulk_case(), fuel in arb_fuel()) { let r = catch_quiet(|| run_bulk(case, fuel)).map_err(|msg| TestCaseError::fail(format!("panicked on {case:?}: {msg}")))?; @@ -566,17 +607,18 @@ proptest! { renders(&s, "after mutated modules")?; } - /// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated - /// permissions, junk of every type, control and bidirectional override characters) parse to - /// an error or to a manifest whose permissions fit its kind, and installing one with a junk - /// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden + /// Manifests with hostile `permissions`, `api`, names and `extensions` (unknown, reserved and + /// repeated permissions, junk of every type, control and bidirectional override characters, + /// extensions missing, dotted, mixed-case, repeated, too long or too many) parse to an error + /// or to a manifest whose permissions and extensions fit its kind, and installing one with a + /// junk `grant` ends as an error or an install, never a panic. A prompt never shows a hidden /// 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}"), ], @@ -585,21 +627,43 @@ proptest! { permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 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", "progress"]))], + grant in prop_oneof![ + Just(Value::Null), + arb_junk(), + Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress"])) + ], + extensions in prop::option::weighted(0.5, arb_extensions()), ) { let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); - if kind == "exporter" { - m["extensions"] = json!(["fz"]); + // Hostile extensions come with a valid `api` and no `permissions`, so the parse reaches + // them. The other half of the cases keep a valid list and reach the other fields. + let hostile_extensions = extensions.is_some(); + match extensions { + None if matches!(kind, "exporter" | "importer") => m["extensions"] = json!(["fz"]), + None | Some(None) => {} + Some(Some(hostile)) => m["extensions"] = hostile, + } + if hostile_extensions { + m["api"] = json!(1); + } else { + m["permissions"] = match permissions { + Some(Value::Null) | None => json!(names), + Some(junk) => junk, + }; } - m["permissions"] = match permissions { - Some(Value::Null) | None => json!(names), - Some(junk) => junk, - }; let text = m.to_string(); let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; if let Ok(man) = &parsed { prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind, !man.commands.is_empty())), "{}", text); + let exts = &man.extensions; + if matches!(man.kind, Kind::Exporter | Kind::Importer) { + prop_assert!((1..=8).contains(&exts.len()), "{}", text); + prop_assert!(exts.iter().all(|e| (1..=16).contains(&e.len()) && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())), "{}", text); + prop_assert!(exts.iter().enumerate().all(|(i, e)| !exts[..i].contains(e)), "{}", text); + } else { + prop_assert!(exts.is_empty(), "{}", text); + } let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); @@ -624,7 +688,7 @@ proptest! { /// error or an edit, never a panic, and the document then renders and exports. #[test] fn hostile_manifest_commands_never_crash( - kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + kind in prop::sample::select(vec!["filter", "effect", "exporter", "importer"]), entries in prop::collection::vec( prop_oneof![ 3 => ( @@ -655,7 +719,7 @@ proptest! { given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()], ) { let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind}); - if kind == "exporter" { + if matches!(kind, "exporter" | "importer") { m["extensions"] = json!(["fz"]); } m["commands"] = junk.unwrap_or(json!(entries)); diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs new file mode 100644 index 000000000..71f21e10d --- /dev/null +++ b/crates/engine/tests/plugin_import.rs @@ -0,0 +1,409 @@ +//! 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 canceled. +//! +//! 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}; +use vectorcraft_plugins::{Limits, Plugin}; + +/// 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]}]}]}}]}"#; + +/// The `vc_run` body of an importer that never returns. +const SPINS: &str = "(loop $l (br $l)) (i64.const 0)"; + +/// 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() +} + +/// The open documents' count, the active one's index, its undo and redo steps, whether it has +/// unsaved changes, and its content. +fn state(s: &mut Session) -> (usize, Option, usize, usize, bool, Value) { + let content = s.execute("document.inspect", &json!({})).unwrap(); + let d = s.doc().unwrap(); + (s.documents().len(), s.active_index(), d.history.undo.len(), d.history.redo.len(), d.is_dirty(), content) +} + +/// Opens a document through a new importer `id`, with an undo step and a redo step → its [`state`]. +fn open_edited(s: &mut Session, id: &str) -> (usize, Option, usize, usize, bool, Value) { + install(s, &tpl::returning(&tpl::importer(id, r#"["tsk"]"#, "{}"), RECT)); + open(s, "Kept.tsk", b"?", json!({"importer": format!("plugin.{id}")})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = state(s); + assert!(before.2 > 0 && before.3 > 0, "{before:?}"); + before +} + +/// 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)); + + // Art at the edge of the canvas keeps its artboard within ±4,000,000 pt, so the document can + // still be edited: a vertical line at x = 4,000,000 (its 1 pt moves inward) and a stroked line + // along y = -4,000,000 (its stroke passes the edge). + let edge = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[4000000,0]},{"p":[4000000,50]}]}]}}]}"#; + open(&mut s, "Edge.tso", edge.as_bytes(), json!({})).unwrap(); + assert_eq!(artboard(&s), (3_999_999.0, 0.0, 4_000_000.0, 50.0)); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap(); + let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#; + open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap(); + let (x0, y0, x1, y1) = artboard(&s); + assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); + + // 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); +} + +/// File › Open lists an importer's extensions only where it would open the file with the importer: +/// not those a built-in format reads, and not those it opens as a plug-in, a shortcut set, a +/// script, presets or a library. An importer with none of its own gets no filter. +#[test] +fn file_open_lists_only_the_extensions_it_opens_with_an_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.claimed", r#"["tcl","ase","wasm","kys","jsx","vcstyles","svg"]"#, "{}"), RECT)); + install(&mut s, &tpl::returning(&tpl::importer("test.claimedonly", r#"["ase","gpl"]"#, "{}"), RECT)); + for ext in ["ase", "gpl", "wasm", "kys", "jsx", "vcstyles"] { + assert!(fileio::opens_as_other(ext), "{ext}"); + assert!(!fileio::open_extensions().contains(&ext), "All readable files: {ext}"); + } + assert!(!fileio::opens_as_other("tcl") && !fileio::opens_as_other("svg")); + assert!(fileio::open_extensions().contains(&"tcl")); + let filters: Vec<_> = fileio::open_filters().collect(); + assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}"); + assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}"); + assert!(filters.contains(&("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS)), "{filters:?}"); + s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap(); + s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap(); +} + +/// 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":[]}"#)); + install(&mut s, &tpl::returning(&tpl::exporter("test.names.exporter", r#"["tsx"]"#, "{}"), r#"{"dataBase64":""}"#)); + 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 plug-in of kind `filter`, not an importer"), + (json!({"importer": "plugin.test.names.exporter"}), "x.tsn", "is a plug-in of kind `exporter`, 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 (i, needle) in needles.into_iter().enumerate() { + // RECT when the parameters hold the needle; the file, which isn't JSON, otherwise. + let wasm = wat::parse_str(tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": i > 0})).unwrap(); + 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"); + + // A document that is already open keeps its tab, content and undo and redo steps. + install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), SPINS, "", 1)); + let before = open_edited(&mut s, "test.fail.opens"); + for id in cases.iter().map(|c| c.0).chain(["test.fail.traps"]) { + assert!(open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).is_err(), "{id}"); + assert_eq!(state(&mut s), before, "{id}"); + } + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.spins"}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert_eq!(state(&mut s), before, "canceled"); +} + +/// Cancel stops an importer between two slices of its budget, and nothing opens: here one that +/// never returns, run with a token canceled beforehand. +#[test] +fn a_canceled_open_stops_the_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), SPINS, "", 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()); +} + +/// An importer that never returns, held to a small import budget, ends at its instruction limit +/// or at its time limit (0 ms, which has passed at the first check between two slices of the +/// budget). The open reports the limit, opens no document, and leaves an open document's content +/// and undo and redo steps as they were. +#[test] +fn an_importer_past_its_budget_opens_nothing() { + let cases = [ + ("test.budget.fuel", Limits { export_fuel: 1_000_000, ..Limits::default() }, "instruction budget"), + ("test.budget.time", Limits { export_fuel: 1_000_000_000, export_wall_time_ms: 0, ..Limits::default() }, "time budget"), + ]; + for (id, limits, _) in &cases { + let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap(); + vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()); + } + let mut s = Session::new(); + for (id, _, why) in &cases { + let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why) && e.to_string().contains("x.tbg"), "{id}: {e}"); + assert!(s.documents().is_empty(), "{id}"); + } + let before = open_edited(&mut s, "test.budget.opens"); + for (id, _, why) in &cases { + let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why), "{id}: {e}"); + assert_eq!(state(&mut s), before, "{id}"); + } + for (id, _, _) in &cases { + vectorcraft_plugins::registry::remove(id); + } +} + +/// A file past the importer's input limit is not read past it, and doesn't open: an open document +/// keeps its content and its undo and redo steps. Given as data, it is refused the same way. +#[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"); + let max = vectorcraft_plugins::import::MAX_INPUT_BYTES; + std::fs::File::create(&path).unwrap().set_len(max 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 before = open_edited(&mut s, "test.large.opens"); + let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert_eq!(state(&mut s), before, "by path"); + let e = open(&mut s, "big.tsb", &vec![0; max + 1], json!({})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert_eq!(state(&mut s), before, "as data"); + // The size is checked before the text is decoded: this text is not base64 at all. + let not_base64 = "!".repeat((max + 1) / 3 * 4 + 8); + for extra in [json!({}), json!({"importer": "plugin.test.large"})] { + let mut p = json!({"name": "big.tsb", "dataBase64": not_base64}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let e = s.execute("document.open", &p).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert_eq!(state(&mut s), before, "before decoding"); + } + // White space is not part of the file's size. + let spaced = format!("QUJD{}", " ".repeat(max / 3 * 4 + 8)); + if let Err(e) = s.execute("document.open", &json!({"name": "small.tsb", "dataBase64": spaced})) { + assert!(!e.to_string().contains("larger than"), "{e}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file past the importer's input limit whose start shows a built-in format opens as that format, +/// whatever its extension; named, the importer's limit applies to it. +#[test] +fn a_built_in_file_past_the_input_limit_still_opens() { + let dir = tmp_dir("largebuiltin"); + let mut s = Session::new(); + echo(&mut s, "test.largepng", r#"["tsp"]"#); + let path = dir.join("big.tsp"); + let mut png = std::io::Cursor::new(vec![]); + image::RgbaImage::new(2, 2).write_to(&mut png, image::ImageFormat::Png).unwrap(); + std::fs::write(&path, png.get_ref()).unwrap(); + std::fs::OpenOptions::new().write(true).open(&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), "importer": "plugin.test.largepng"})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert!(s.documents().is_empty()); + let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap(); + assert_eq!(r["format"], "png"); + let mut data = png.into_inner(); + data.resize((vectorcraft_plugins::import::MAX_INPUT_BYTES + 3) / 3 * 3, 0); + let encoded = json!({"name": "big.tsp", "dataBase64": vectorcraft_format::base64_encode(&data)}); + assert_eq!(s.execute("document.open", &encoded).unwrap()["format"], "png"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index b8a9bafc0..3ca8a66b5 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -169,7 +169,8 @@ //! layers and opacity masks nested too deep, and, on objects written without an art style, live //! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to //! graphic styles on objects not written with them, and every art style on a large canvas, are left -//! out. +//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), +//! counted as the reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -242,6 +243,11 @@ pub struct AiEditingOutput { /// Write `doc` as the editing data of a `.ai` file: every artboard and layer. pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result { + export_within(doc, o, crate::import::MAX_EDITING_OBJECTS) +} + +/// [`export_ai_editing`] writing at most `max_objects` objects, counted as the reader counts them. +fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Result { let board = doc.artboards.first().ok_or("the document has no artboard")?.rect; if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); @@ -283,6 +289,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Vec<(&'static str, Document)> { + vec![ + ("sample", sample()), + ("unsupported", unsupported()), + ("transparent", transparent()), + ("gradients and patterns", gradients_and_patterns()), + ("images", images_doc()), + ("typed", typed()), + ("variables", variables_doc()), + ("symbols", symbols_document()), + ("styles", styles_document()), + ("painted clips", painted_clips()), + ("blend", blend_doc()), + ] +} + +/// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps. +fn blend_doc() -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let keys = vec![ + Arc::new(square(&mut doc, Rect::new(10.0, 10.0, 12.0, 12.0), Color::gray(0.5))), + Arc::new(square(&mut doc, Rect::new(30.0, 10.0, 32.0, 12.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.insert(Some(layer), usize::MAX, blend).unwrap(); + doc +} + +/// Clipping groups whose clipping paths are filled: a square with a color, and a ring (a path of +/// two subpaths) with a color, with a gradient, and with a color and a gradient stroke. +fn painted_clips() -> Document { + let mut doc = Document::new(260.0, 100.0); + let layer = doc.layers[0].id; + let ring = |x: f64| { + let mut p = shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)); + p.subpaths.extend(shapes::rectangle(Rect::new(x + 5.0, 15.0, x + 15.0, 25.0)).subpaths); + p + }; + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + let shade = || Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let clips = [ + (10.0, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None), + (70.0, ring(70.0), solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None), + (130.0, ring(130.0), shade(), Paint::None), + (190.0, ring(190.0), solid(Color::rgb(0.0, 1.0, 0.0)), shade()), + ]; + for (x, path, fill, stroke) in clips { + let mut clip = clipping(&mut doc, path); + let width = if matches!(stroke, Paint::None) { 0.0 } else { 2.0 }; + clip.appearance = Appearance::basic(fill, stroke, width).into(); + let inside = square(&mut doc, Rect::new(x - 5.0, 5.0, x + 10.0, 20.0), Color::gray(0.5)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + } + doc +} + +/// The writer counts the objects it writes as the reader counts the objects it reads: with room +/// for as many objects as the reader reads, nothing is left out; with one less, art is left out, +/// with a warning. With room for fewer, the reader never reads more objects than there is room for. +#[test] +fn the_writer_counts_objects_as_the_reader_reads_them() { + let objects = |out: &AiEditingOutput| crate::import::objects_read(&out.program).unwrap(); + let limited = |out: &AiEditingOutput| out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT); + for (name, doc) in counted() { + let all = export_ai_editing(&doc, &opts()).unwrap(); + let read = objects(&all); + assert!(read > 0 && !limited(&all), "{name}: {read} objects, {:?}", all.warnings); + let room = export_within(&doc, &opts(), read).unwrap(); + assert!(!limited(&room), "{name}: nothing is left out with room for {read} objects"); + assert_eq!(room.program, all.program, "{name}"); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + assert!(limited(&out), "{name}: art is left out with room for {max} of {read} objects"); + assert!(objects(&out) <= max, "{name}: {} objects read with room for {max}", objects(&out)); + } + } +} + +/// An opacity mask whose art doesn't all fit within the object limit is left out with its art, +/// and the object it masks is written without it; the art left out leaves room for the objects +/// after it. +#[test] +fn a_mask_whose_art_passes_the_object_limit_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5)); + let a = square(&mut doc, Rect::new(0.0, 0.0, 30.0, 30.0), Color::rgb(1.0, 1.0, 1.0)); + let b = square(&mut doc, Rect::new(30.0, 30.0, 60.0, 60.0), Color::rgb(1.0, 1.0, 1.0)); + let art = group(&mut doc, vec![a, b], false); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, masked).unwrap(); + let after = square(&mut doc, Rect::new(100.0, 10.0, 140.0, 50.0), Color::gray(0.5)); + doc.insert(Some(layer), usize::MAX, after).unwrap(); + let text = |max: usize| { + let out = export_within(&doc, &opts(), max).unwrap(); + let limited = out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT); + (String::from_utf8_lossy(&out.program).replace("\r\n", "\n"), limited) + }; + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let (all, limited) = text(read); + assert!(!limited && all.contains("(AI9_Mask)"), "{all}"); + let has_mask = |max: usize| text(max).0.contains("(AI9_Mask)"); + let Some(fit) = (0..=read).find(|&max| has_mask(max)) else { panic!("the mask fits with room for {read} objects") }; + assert!(fit > 2, "the mask needs room beyond the two squares: {fit}"); + for max in fit..=read { + assert!(has_mask(max), "room for {max} of {read}, the mask fits in {fit}"); + } + let (short, limited) = text(fit - 1); + assert!(limited); + assert!(!short.contains("(AI9_Mask)") && !short.contains("%_/ArtDictionary :\n%_/Mask"), "{short}"); + assert_eq!(short.matches("\nf\n").count(), 2, "both squares are written: {short}"); +} + +/// A symbol's definition is written only when there is room for its instance too. +#[test] +fn a_symbol_definition_is_written_only_with_room_for_its_instance() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let a = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5)); + let b = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5)); + let art = group(&mut doc, vec![a, b], false); + doc.symbols.push(Symbol { dynamic: false, name: "Pair".into(), art: Arc::new(art) }); + let placed = instance(&mut doc, "Pair", Affine::translate((50.0, 10.0))); + doc.insert(Some(layer), usize::MAX, placed).unwrap(); + add_rect(&mut doc, Rect::new(100.0, 10.0, 110.0, 20.0)); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let all = String::from_utf8_lossy(&program).replace("\r\n", "\n"); + assert!(all.contains("%AI14_BeginSymbol") && all.contains("/SymbolInstance :"), "{all}"); + let read = crate::import::objects_read(&program).unwrap(); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + let defined = text.contains("%AI14_BeginSymbol"); + assert_eq!(defined, text.contains("/SymbolInstance :"), "room for {max} of {read}: {text}"); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "art is left out with room for {max} of {read} objects"); + let objects = crate::import::objects_read(&out.program).unwrap(); + assert!(objects <= max, "{objects} objects read with room for {max}"); + } +} + +/// The writer's limit is the reader's, which its warning names. +#[test] +fn the_object_limit_is_the_readers() { + assert_eq!(crate::import::MAX_EDITING_OBJECTS, 4_000_000); + assert!(writer::OBJECT_LIMIT.contains("4,000,000")); +} + +/// Paths past the object limit are left out of a layer of many paths, with a warning: the paths +/// that fit are written. +#[test] +fn paths_past_the_object_limit_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..10 { + add_rect(&mut doc, Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0)); + } + let out = export_within(&doc, &opts(), 6).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert_eq!(crate::import::objects_read(&out.program).unwrap(), 6); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert_eq!(text.matches("\nf\n").count(), 6, "the first six paths are written"); +} + /// On a canvas of the usual size, nested symbols whose instances multiply, written as their art in /// art cut to a clipping object, stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art /// in all, with a warning. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 8dd98415f..0c3587e58 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -62,6 +62,7 @@ const GROUPED_LAYER: &str = pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches of more than 256 stops, and pattern swatches whose patterns the data can't define, are left out of the editing data for other apps"; pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; +pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; /// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]), @@ -117,6 +118,16 @@ fn blend_code(b: BlendMode) -> u8 { } } +/// What the writer had charged when a part of the art started ([`Writer::charges`]). +#[derive(Clone, Copy)] +struct Charges { + objects: usize, + symbol_nodes: u64, + kept_objects: usize, + kept_symbol_nodes: u64, + left_out: usize, +} + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -144,6 +155,18 @@ pub(super) struct Writer<'a> { brushes: Option>, /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. symbol_nodes: u64, + /// The objects written so far, counted as the reader counts the objects it reads. + objects: usize, + /// How many times art was left out for [`Self::max_objects`]. + left_out: usize, + /// The objects of the pattern and symbol definitions written so far, which stay written when + /// the art that first used them is not ([`Self::give_back`]). + definition_objects: usize, + /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). + definition_symbol_nodes: u64, + /// The most objects written ([`Self::take_objects`]): the most the reader reads, + /// [`crate::import::MAX_EDITING_OBJECTS`]. + pub(super) max_objects: usize, /// The document → the space gradients' matrices (`Xm`, `Bm`, `Bc`) and focal points are /// written in: art space, and in a pattern's definition [`Self::tile_grad_space`]. grad_space: Affine, @@ -286,6 +309,85 @@ impl<'a> Writer<'a> { styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, + objects: 0, + left_out: 0, + definition_objects: 0, + definition_symbol_nodes: 0, + max_objects: crate::import::MAX_EDITING_OBJECTS, + } + } + + /// Whether `count` more objects may be written: the objects written so far, counted as the + /// reader counts them ([`object_count`]), stay within [`Self::max_objects`]. Past it the art is + /// left out, with a warning. + fn take_objects(&mut self, count: usize) -> bool { + let total = self.objects.saturating_add(count); + if total > self.max_objects { + self.warn(OBJECT_LIMIT); + self.left_out += 1; + return false; + } + self.objects = total; + true + } + + /// Start a group with `op` (`u`, or `q` for a clipping group), counted as one object + /// ([`Self::take_objects`]). False, writing nothing, when there is no room for it. + fn open_group(&mut self, op: &str) -> bool { + if !self.take_objects(1) { + return false; + } + self.line(op); + true + } + + /// Whether `n`, an instance of `symbol` placed by `xf`, is written as a live instance + /// ([`Self::live_instance`]). The instance is one object, without the art of its definition, + /// and room for it is kept before the definition is written. + fn live_instance_in_room(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + if !self.take_objects(1) { + return false; + } + if self.live_instance(n, symbol, xf) { + return true; + } + self.objects -= 1; + false + } + + /// What has been charged so far, for [`Self::keep_since`] and [`Self::give_back`]. + fn charges(&self) -> Charges { + Charges { + objects: self.objects, + symbol_nodes: self.symbol_nodes, + kept_objects: self.definition_objects, + kept_symbol_nodes: self.definition_symbol_nodes, + left_out: self.left_out, + } + } + + /// Whether art was left out for [`Self::max_objects`] since `c`. + fn left_out_since(&self, c: Charges) -> bool { + self.left_out != c.left_out + } + + /// A definition that started at `c` is written: what was charged since stays charged when the + /// art that first used it is given back ([`Self::give_back`]). + fn keep_since(&mut self, c: Charges) { + self.definition_objects = c.kept_objects + (self.objects - c.objects); + self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + } + + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are + /// given back, except those of the definitions written since, and the images `lines` marks + /// are emptied (the others keep their indices). + fn give_back(&mut self, c: Charges, lines: &str) { + self.objects = c.objects + (self.definition_objects - c.kept_objects); + self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + for index in lines.split(PAYLOAD).skip(1).step_by(2) { + if let Some(p) = index.parse::().ok().and_then(|i| self.payloads.get_mut(i)) { + *p = Vec::new(); + } } } @@ -510,19 +612,22 @@ impl<'a> Writer<'a> { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), NodeKind::Group { children, clip: false } => { - self.line("u"); - for c in children { - self.node(c); + if self.open_group("u") { + for c in children { + self.node(c); + } + self.end_group("U", xy); } - self.end_group("U", xy); } NodeKind::Path { path, rule, guide, .. } => { if *guide { - // Its own transparency, not the one the object before it set. - if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) { + // A path for each subpath with points, with the guide's own transparency, not + // the one the object before it set. + let subpaths = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count(); + if subpaths > 0 && self.take_objects(subpaths) { self.set_object_xy(xy.object()); + ai_ops::guide(&mut self.body, self.to_art, path, true); } - ai_ops::guide(&mut self.body, self.to_art, path, true); } else { self.shape(n, path, *rule, xy); } @@ -530,8 +635,11 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), - NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { + if !self.open_group("u") { + return; + } // The canvas places an instance inside a symbol's art where the outer instance puts // it (9-slice guides, stains and patterns included), and this art doesn't move with it. self.wrote_fixed |= self.in_symbol; @@ -541,7 +649,6 @@ impl<'a> Writer<'a> { } else { self.warn(symbols::MISSING_SYMBOL); } - self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -583,8 +690,10 @@ impl<'a> Writer<'a> { | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + if !self.open_group("u") { + return; + } self.warn(LIVE); - self.line("u"); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.nest += 1; @@ -629,11 +738,14 @@ impl<'a> Writer<'a> { if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() { return; } + let start = self.body.len(); + let has_id = name.is_some() || !bound.is_empty(); + let has_entries = entries.is_some(); self.body.push_str("%_/ArtDictionary :\n"); if let Some(e) = entries { self.body.push_str(&e); } - if name.is_some() || !bound.is_empty() { + if has_id { let id = self.take_id(name.unwrap_or("XMLID")); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); if name.is_none() { @@ -644,8 +756,13 @@ impl<'a> Writer<'a> { self.bound.push((n.id, id, bound)); } } - if let Some(m) = mask { - self.mask(m); + // A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too. + if let Some(m) = mask + && !self.mask(m) + && !has_id + && !has_entries + { + return self.body.truncate(start); } self.body.push_str("%_;\n%_\n"); } @@ -673,10 +790,13 @@ impl<'a> Writer<'a> { /// An opacity mask, as an entry of its object's dictionary: `/Mask :`, `X=`, its art on `%_` /// lines (from the state a dictionary's art starts in: unlocked, shown, opaque), `X+`, `(Art) ,`, /// its options as the `/Bool` entries `Clipping`, `Inverted`, `Disabled` and `Linked`, and - /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. - fn mask(&mut self, m: &OpacityMask) { + /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. False, writing + /// nothing, when some of the art is left out for the object limit ([`Self::take_objects`]): a + /// mask without all its art would change what it shows. + fn mask(&mut self, m: &OpacityMask) -> bool { let outer = std::mem::take(&mut self.body); - let (state, clip) = (self.state, self.clip.take()); + let (state, gs, clip) = (self.state, self.gs, self.clip.take()); + let (charges, bound) = (self.charges(), self.bound.len()); (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); // The canvas draws the art's root even when it is hidden, and the reader puts a hidden object // in a group that doesn't draw it, so a hidden root is written shown. @@ -709,6 +829,13 @@ impl<'a> Writer<'a> { self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); + if self.left_out_since(charges) { + // The art isn't written, nor are the variable bindings of its objects. + self.give_back(charges, &lines); + self.bound.truncate(bound); + (self.state, self.gs, self.clip, self.block_end) = (state, gs, clip, usize::MAX); + return false; + } self.body.push_str("%_/Mask :\n%_X=\n"); hide(&lines, &mut self.body); self.body.push_str("%_X+\n%_(Art) ,\n"); @@ -718,6 +845,7 @@ impl<'a> Writer<'a> { self.body.push_str("%_; (AI9_Mask) ,\n"); // The dictionary ends with the state before it, except the transparency its art set last. (self.state, self.gs, self.clip, self.block_end) = (state, None, clip, usize::MAX); + true } /// Warn that art is cut to clipping object `first` when it isn't a path or compound path (a path @@ -739,12 +867,16 @@ impl<'a> Writer<'a> { if self.clip.is_some() && !children.is_empty() { self.warn(CLIPPING); } - self.line("u"); - self.clip_contents(children); - return self.end_group("U", xy); + if self.open_group("u") { + self.clip_contents(children); + self.end_group("U", xy); + } + return; } let at_q = (self.state, self.gs); - self.line("q"); + if !self.open_group("q") { + return; + } self.clipped(children); (self.state, self.gs) = at_q; self.end_group("Q", xy); @@ -753,7 +885,8 @@ impl<'a> Writer<'a> { /// The members of a clipping group or layer, the first one clipping the others: the others, /// then the first one as the clipping path (see [`Self::clip_group`]), its name after it. Fills /// and strokes the clipping path can't paint itself are paths of their own, the fills under the - /// others and the strokes over them. + /// others and the strokes over them. Without room for the clipping path, nothing is written + /// ([`Self::take_objects`]). fn clipped(&mut self, children: &[Arc]) { let Some((first, rest)) = children.split_first() else { return }; let Some((path, rule)) = clip_path(first) else { @@ -762,6 +895,12 @@ impl<'a> Writer<'a> { }; let items = self.items(first, path.to_bezpath().bounding_box()); let painted = self.plain_paint(&items); + // The clipping path goes after the others, so its objects are counted first. + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let (fill, stroke) = painted.unwrap_or_default(); + if !self.take_objects(clip_object_count(&subpaths, fill, stroke)) { + return; + } if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { self.warn(ITEM_TRANSPARENCY); } @@ -779,12 +918,10 @@ impl<'a> Writer<'a> { self.node(c); } self.clip_paint_part(first, &path, rule, &strokes); - let (fill, stroke) = painted.unwrap_or_default(); self.state(first.locked, !first.visible); let start = self.body.len(); self.paint_state(fill, stroke, Xy::of(first).object(), rule); let op = paint_op(fill.is_some(), stroke.is_some()); - let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); let several = subpaths.len() > 1; if several { self.line("*u"); @@ -817,7 +954,9 @@ impl<'a> Writer<'a> { if let [one] = items { return self.item(path, rule, one, xy); } - self.line("u"); + if !self.open_group("u") { + return; + } for i in items { self.item(path, rule, i, Xy::PLAIN); } @@ -870,11 +1009,12 @@ impl<'a> Writer<'a> { (Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy), (None, [one]) => self.item(path, rule, one, xy), (None, items) => { - self.line("u"); - for i in items { - self.item(path, rule, i, Xy::PLAIN); + if self.open_group("u") { + for i in items { + self.item(path, rule, i, Xy::PLAIN); + } + self.end_group("U", xy); } - self.end_group("U", xy); } } } @@ -898,7 +1038,8 @@ impl<'a> Writer<'a> { } /// A brushed stroke `st` of `path` as a group of its brush's art, of transparency `xy`. False - /// when it has no brush the document knows (or brushes nest too deep). + /// when it has no brush the document knows (or brushes nest too deep). Without room for the + /// group ([`Self::take_objects`]), the stroke is left out. fn brush_art(&mut self, path: &PathData, st: &StrokeLayer, xy: Xy) -> bool { if self.nest >= MAX_NEST { return false; @@ -908,8 +1049,10 @@ impl<'a> Writer<'a> { let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st); let state = self.state; + if !self.open_group("u") { + return true; + } self.nest += 1; - self.line("u"); for piece in &art { self.node(piece); } @@ -948,13 +1091,15 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + let state = self.state; + if !self.open_group("u") { + return; + } self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); self.nest += 1; - let state = self.state; - self.line("u"); for t in &tiles { self.node(t); } @@ -979,8 +1124,8 @@ impl<'a> Writer<'a> { } let both = fill.is_some() && stroke.is_some(); let own = if both { Xy::OPAQUE } else { xy.object() }; - if both { - self.line("u"); + if both && !self.open_group("u") { + return; } if fill.is_some() || stroke.is_none() { let region = self.cut(path, rule); @@ -1026,7 +1171,7 @@ impl<'a> Writer<'a> { /// points and painting operator (`*u` … `*U` around several). fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) { let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); - if subpaths.is_empty() { + if subpaths.is_empty() || !self.take_objects(object_count(subpaths.len())) { return; } self.paint_state(fill, stroke, xy, rule); @@ -1057,18 +1202,29 @@ impl<'a> Writer<'a> { fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { - None - } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { + let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); + // Type that can't be a stub is written as its outlines, which count their own objects. + if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + return self.outlined_text(n, xy); + } + // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for + // them, the type is left out before its story goes into the text document. + let stub = texts::stub_objects(t); + if !self.take_objects(stub) { + return; + } + let placed = if let Some(thread) = thread { // A thread is checked for what it loses, in every frame, when its story is written. let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id)); placed.map(|p| (p, self.texts.take_lost())) - } else if texts::holds(n, t) { - self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) } else { - None + self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) + }; + let Some(((story, frame), lost)) = placed else { + // The text document can't hold it: the outlines count their own objects. + self.objects -= stub; + return self.outlined_text(n, xy); }; - let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) }; self.typed = true; if lost { self.warn(texts::TYPE_ATTRIBUTES); @@ -1088,8 +1244,10 @@ impl<'a> Writer<'a> { /// graphics as their symbols' art where the layout places them, as the PDF page draws them; /// the group has the type's transparency `xy`. fn outlined_text(&mut self, n: &Node, xy: Xy) { + if !self.open_group("u") { + return; + } self.warn(TYPE); - self.line("u"); let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), @@ -1343,6 +1501,30 @@ fn paint_op(fill: bool, stroke: bool) -> char { } } +/// The objects the reader makes of a path object of `subpaths` subpaths with points: a path for +/// each, and a compound path holding them when there are several. +fn object_count(subpaths: usize) -> usize { + match subpaths { + 0 => 0, + 1 => 1, + n => n + 1, + } +} + +/// The objects the reader counts for clipping path `subpaths` (those with points) painted with +/// `fill` and `stroke` ([`Writer::clipped`]). Its last subpath carries a gradient fill's or +/// stroke's gradient, so the subpaths of a filled compound clipping path with a gradient paint +/// differently, and the reader reads their fills apart from it: a compound path of a copy of each, +/// which counts the copy and its points ([`object_count`] for the clipping path itself). +fn clip_object_count(subpaths: &[&SubPath], fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) -> usize { + let own = object_count(subpaths.len()); + let apart = subpaths.len() > 1 && fill.is_some() && (gradient(fill.map(|f| &f.paint)).is_some() || gradient(stroke.map(|s| &s.paint)).is_some()); + if !apart { + return own; + } + subpaths.iter().fold(own.saturating_add(1), |n, sp| n.saturating_add(1).saturating_add(sp.anchors.len())) +} + /// The subpaths of compound path `n`'s members, as one path. fn compound_path(n: &Node) -> PathData { let mut subpaths = vec![]; diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 898c91aa7..36c21c14e 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -107,6 +107,11 @@ impl Writer<'_> { if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) { return; } + // The image, and where it is cut, the clipping group and its clipping path. + let clipping = self.clip.as_ref().map_or(0, |c| 1 + super::object_count(c.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count())); + if !self.take_objects(1 + clipping) { + return; + } if deep { self.warn(IMAGE_DEPTH); } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index b4966968e..b9d28253d 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -381,7 +381,8 @@ impl Writer<'_> { /// Write pattern `name`'s definition, once: `%AI3_BeginPattern: (name)`, `(name) llx lly urx /// ury` (the tile, y up), the tile's box (an unpainted path whose dictionary marks it, /// `AIPattern_Editor_Backing_Tile_Rect`) and the art on `%_` lines, `E`, `%AI3_EndPattern`. - /// False when it can't be ([`Self::pattern_writable`]). + /// False when it can't be ([`Self::pattern_writable`]), or when its box and art don't all fit + /// within the object limit ([`Self::take_objects`]). fn define_pattern(&mut self, name: &str) -> bool { if !self.pattern_writable(name) { return false; @@ -392,7 +393,9 @@ impl Writer<'_> { return true; } let doc = self.doc; - let Some(def) = doc.pattern(name) else { return false }; + // The tile's box is an object of the definition ([`Self::take_objects`]). + let (before, bound) = (self.charges(), self.bound.len()); + let Some(def) = doc.pattern(name).filter(|_| self.take_objects(1)) else { return false }; self.defining.push(name.to_string()); // The art is written in the pattern's space, y up, from the state a definition starts in. let saved = ( @@ -427,6 +430,12 @@ impl Writer<'_> { (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); + if self.left_out_since(before) { + self.give_back(before, &art); + self.bound.truncate(bound); + return false; + } + self.keep_since(before); let tile = Affine::FLIP_Y.transform_rect_bbox(def.tile); let quoted = ps::bytes(name.as_bytes()); let mut text = format!("%AI3_BeginPattern: {quoted}\n{quoted} "); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 1f10bb4c8..65fe91859 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -688,7 +688,18 @@ impl Writer<'_> { /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or /// compound path's points painted with nothing, or with its gradient fill when its style has /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + /// The look's group and the object itself are counted first ([`Self::take_objects`]): without + /// room for them, nothing is written. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + let own = match &orig.kind { + NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), + NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), + NodeKind::Group { .. } => 1, + _ => 0, + }; + if !self.take_objects(1 + own) { + return; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -717,6 +728,10 @@ impl Writer<'_> { let saved = std::mem::take(&mut self.body); self.hidden += 1; let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + // A path's objects were kept for it above; it counts them itself. + if !matches!(orig.kind, NodeKind::Group { .. }) { + self.objects -= own; + } match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index bfc1ccc3c..9b9724f84 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -183,8 +183,9 @@ impl Writer<'_> { /// written from the top of the nesting whatever the instance that asks for it, so a definition /// is the same wherever its first instance lies. False when it can't be: the document doesn't /// have the symbol, it is being written (its art holds an instance of itself), it is reached - /// through more than [`MAX_NEST`] definitions being written, or the definitions written and - /// being written leave no place under [`MAX_SYMBOLS`]. + /// through more than [`MAX_NEST`] definitions being written, the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`], or its art doesn't all fit within the + /// object limit ([`Self::take_objects`]). pub(super) fn define_symbol(&mut self, name: &str) -> bool { if self.defined_symbols.contains(name) { return true; @@ -206,6 +207,8 @@ impl Writer<'_> { // The art's effects are its own, not those of a drawn look that first places it. let in_look = std::mem::take(&mut self.in_look); self.defining_symbols.push(name.to_string()); + // The objects of this definition and of those it defines first. + let before = self.charges(); let mut used = vec![]; vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { @@ -217,10 +220,19 @@ impl Writer<'_> { } if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { self.defining_symbols.pop(); + self.keep_since(before); self.warn(SYMBOL_COUNT); self.in_look = in_look; return false; } + // The group that holds the art is an object of the definition ([`Self::take_objects`]); + // without room for it, or for all the art, there is no definition. + let (art_start, bound) = (self.charges(), self.bound.len()); + if !self.take_objects(1) { + self.defining_symbols.pop(); + self.keep_since(before); + return false; + } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the // document at its own size. let origin = self.canvas_origin; @@ -264,7 +276,9 @@ impl Writer<'_> { let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); } else { // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + // It counts itself, in the room kept for it above. self.root_entries = Some((root.id, entries)); + self.objects -= 1; self.node(root); } self.nest = nest; @@ -297,6 +311,13 @@ impl Writer<'_> { self.in_look = in_look; let art = std::mem::replace(&mut self.body, art); self.defining_symbols.pop(); + if self.left_out_since(art_start) { + self.give_back(art_start, &art); + self.bound.truncate(bound); + self.keep_since(before); + return false; + } + self.keep_since(before); let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); self.defined_symbols.insert(name.to_string()); self.symbol_defs.push(text); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index f494e859a..a84fc5720 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -1016,6 +1016,12 @@ fn clone_v(v: &V) -> V { } } +/// The objects the reader reads of [`stub`] for `t`: the text object, and a path for each subpath +/// of its frame's path. +pub(in crate::ai_editing) fn stub_objects(t: &TextObject) -> usize { + 1 + shape(t).map_or(0, |p| p.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()) +} + /// The text object stub of story `story`, frame `frame`: `/AI11Text :`, `/FreeUndo`, `/FrameIndex`, /// `/StoryIndex`, for area type and type on a path the frame's path (`/Art :`, `X=`, the path in /// art space through `to_art`, `X+`, `; /ConfiningPath ,`), `/TextAntialiasing`, `;`. diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 1e0fd01c3..7f1391756 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -73,8 +73,11 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +#[cfg(test)] +pub(crate) use ai::objects_read; pub(crate) use ai::{ - MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number, + MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, + symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c84f3b406..6b5918530 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,13 +41,16 @@ pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; #[cfg(test)] +pub(crate) use read::objects_read; +#[cfg(test)] pub(crate) use read::with_image_limits; pub use read::{ BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, symbols, variables, }; pub(crate) use read::{ - MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member, + MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, + symbol_art_member, }; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d67d5b83e..b01ae54a1 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -149,6 +149,7 @@ pub(crate) use document::{slot_frame, slot_of}; pub(crate) use ops::{read_art_style, read_style_list, style_moves_with}; #[cfg(test)] pub(crate) use raster::with_image_limits; +pub(crate) use types::MAX_NODES; pub use types::PlacedPage; use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead}; #[cfg(test)] @@ -259,6 +260,14 @@ fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result Result { + let mut r = Reader::with_names(data); + r.run()?; + Ok(r.nodes) +} + /// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private /// data): the program's header gives the author and the custom colors the editing data's header /// doesn't. `page_art`: as [`read_over_page`] reads a file. diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 5d34e248e..b07270ef8 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -11,8 +11,8 @@ pub(super) const MAX_SUBREADERS: usize = 32; /// Most operands on the stack. pub(super) const MAX_STACK: usize = 1 << 20; -/// Most objects read. -pub(super) const MAX_NODES: usize = 4_000_000; +/// Most objects read. The `.ai` editing data writer writes no more ([`crate::import::MAX_EDITING_OBJECTS`]). +pub(crate) const MAX_NODES: usize = 4_000_000; /// The most objects all the blends of one import may lay out to be checked (each twice) and made /// live; past it, blends keep their drawn steps. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index b3aa7eab7..5d7aabd07 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -56,7 +56,7 @@ use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; -pub use craft_vectorcraft_format::base64::{decode as base64_decode, encode as base64_encode}; +pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode}; pub use craft_vectorcraft_format::{ BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, sniff, diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 90dc0d060..984d3a123 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -1,16 +1,29 @@ -//! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the -//! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and -//! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes -//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their -//! own modules; the rest of the standard library is not here yet. +//! The built-in objects (ECMA-262 5.1 section 15): `Object` with `Object.prototype` and the +//! ES5 statics, `Function` with `Function.prototype.call`, `apply`, `bind` and `toString`, +//! `Array`, the error constructors, `Math`, `JSON`, the global functions and constants, Annex +//! B's `escape` and `unescape`, and the prototypes primitive values look properties up on, with +//! `Boolean.prototype`'s `toString` and `valueOf`. `String`, `Number`, `RegExp` and `Date` are in +//! their own modules. +//! +//! Every loop in a built-in takes a unit of fuel for each step, and every string, array, object +//! or property it makes is checked against the heap limit before it is made, so no built-in can +//! run past [`crate::Limits`]. + +mod array; +mod global; +mod json; +mod math; +mod object; use std::rc::Rc; use crate::bytecode::{CodeKind, Source}; use crate::compiler::{CompileFail, compile_constructed_function}; -use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key, string_bytes}; -use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; -use crate::value::{Str, Value}; +use crate::heap::{ + BOUND_ARG_BYTES, Class, Func, HIDDEN, Key, NativeFn, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, key, string_bytes, +}; +use crate::interp::{Abrupt, Desc, ErrorType, Interp, JsResult}; +use crate::value::{Str, Value, index_key, number_to_str, to_uint32}; fn alloc(interp: &mut Interp, class: Class, proto: Option) -> ObjId { interp.heap.alloc_object(Object::new(class, proto)) @@ -42,12 +55,16 @@ pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, } /// Creates the built-ins and the global object's properties. The caller installs them before it -/// sets the realm's limits, so no limit stops the install. +/// sets the realm's limits, so no limit stops the install. If it fails, the failure is kept in +/// `interp.install_error` and every run of the realm ends with it. pub(crate) fn install(interp: &mut Interp) { if let Err(e) = try_install(interp) { - // Installing touches only objects it has just made, so this does not happen; a realm - // that somehow lacks a built-in reports it when a script uses it. - let _ = e; + let reason = match e { + Abrupt::Throw(v) => interp.describe(&v), + Abrupt::Limit(kind) => format!("limit reached: {kind:?}"), + Abrupt::Internal(message) => message, + }; + interp.install_error = Some(format!("the built-ins could not be installed: {reason}")); } } @@ -68,7 +85,8 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); - interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); + let boolean_proto = alloc(interp, Class::Boolean(false), Some(object_proto)); + interp.intrinsics.boolean_proto = boolean_proto; interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; if let Some(t) = interp.heap.object_mut(thrower) { @@ -98,15 +116,189 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { data(interp, function_proto, "call", Value::Object(call), HIDDEN)?; let apply = native(interp, "apply", unreachable_native, 2, NativeKind::Apply, false)?; data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; + method(interp, function_proto, "bind", function_bind, 1)?; method(interp, function_proto, "toString", function_to_string, 0)?; + method(interp, boolean_proto, "toString", boolean_to_string, 0)?; + method(interp, boolean_proto, "valueOf", boolean_value_of, 0)?; + install_errors(interp, object_proto)?; + object::install(interp, object)?; + array::install(interp)?; + math::install(interp)?; + json::install(interp)?; + global::install(interp)?; crate::string_builtins::install(interp)?; crate::number_builtins::install(interp)?; crate::regexp_builtins::install(interp)?; crate::date_builtins::install(interp) } +// ---- helpers for the built-ins ---- + +/// ToInteger (section 9.4). +fn to_integer(interp: &mut Interp, v: &Value) -> JsResult { + let n = interp.to_number(v)?; + Ok(if n.is_nan() { 0.0 } else { n.trunc() }) +} + +/// The property name of the index `n`, which may be past the largest array index. +fn key_of(n: f64) -> Key { + if (0.0..4_294_967_295.0).contains(&n) && n.fract() == 0.0 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + return index_key(n as u32); + } + number_to_str(n).flat() +} + +/// ToUint32 of an object's `length`. +fn length_of(interp: &mut Interp, o: ObjId) -> JsResult { + let len = interp.get_from(o, &key("length"), &Value::Object(o))?; + Ok(to_uint32(interp.to_number(&len)?)) +} + +fn get_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { + interp.get_from(o, &key_of(k), &Value::Object(o)) +} + +fn has_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { + interp.has_property(o, &key_of(k)) +} + +/// The estimated bytes a new element adds. +const ELEMENT_BYTES: usize = PROP_BYTES + 32 + 2 * 10; + +/// `o[k] = v` as the array methods assign: a failure throws. When `o` has no own property `k`, `v` +/// stays rooted while heap is reserved for the new element, since the caller may hold it nowhere +/// else; overwriting an own property reserves nothing. +pub(crate) fn put_at(interp: &mut Interp, o: ObjId, k: f64, v: Value) -> JsResult<()> { + let k = key_of(k); + if interp.heap.object(o).is_some_and(|obj| obj.props.contains(&k)) { + return interp.put(o, k, v, true); + } + interp.rooted(std::slice::from_ref(&v), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.put(o, k.clone(), v.clone(), true) + }) +} + +fn delete_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<()> { + interp.delete(o, &key_of(k), true).map(drop) +} + +/// Defines `a[k]` as a writable, enumerable and configurable data property, as the methods that +/// build a new array do (DefineOwnProperty with `throw` false). `v` stays rooted while heap is +/// reserved for it. +fn create_at(interp: &mut Interp, a: ObjId, k: f64, v: Value) -> JsResult<()> { + interp.rooted(std::slice::from_ref(&v), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.define_own_property(a, key_of(k), &Desc::data(v.clone(), OPEN), false).map(drop) + }) +} + +/// A new array with the given length and no elements. +fn new_array(interp: &mut Interp, length: u32) -> JsResult { + interp.reserve_heap(OBJECT_BYTES)?; + let proto = interp.intrinsics.array_proto; + Ok(interp.heap.alloc_object(Object::new(Class::Array { length, length_writable: true }, Some(proto)))) +} + +/// A new string from code units, checked against the heap limit and charged to the heap. +fn new_string(interp: &mut Interp, units: Vec) -> JsResult { + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(Value::String(Str::from_units(units))) +} + +/// Code units a built-in may write into a string for one unit of fuel. +const UNITS_PER_FUEL: usize = 16; + +/// A string the built-ins build piece by piece: each piece takes fuel for its length, is checked +/// against the longest string the heap limit allows, and has its bytes reserved before it is +/// added. The buffer's bytes count toward the heap until `finish` or until the native call that +/// made the builder returns. Everything the caller still needs must be reachable from the roots. +struct Builder { + units: Vec, + /// Code units already reserved and charged to the heap. + charged: usize, + /// Code units written since fuel was last taken, fewer than `UNITS_PER_FUEL`. + unpaid: usize, +} + +impl Builder { + fn new() -> Self { + Builder { units: Vec::new(), charged: 0, unpaid: 0 } + } + + /// Checks that `more` code units fit, reserving heap for them, and takes fuel for writing them. + fn make_room(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { + let want = self.units.len().saturating_add(more); + if want > interp.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + self.unpaid += more; + let fuel = (self.unpaid / UNITS_PER_FUEL) as u64; + self.unpaid %= UNITS_PER_FUEL; + if fuel > 0 { + if interp.fuel < fuel { + interp.fuel = 0; + return Err(Abrupt::Limit(crate::LimitKind::Fuel)); + } + // The last unit goes through `use_fuel`, which also checks for an interrupt. + interp.fuel -= fuel - 1; + interp.use_fuel()?; + } + if want > self.charged { + let grow = (want - self.charged).max(self.charged / 2).max(256).min((interp.limits.heap_bytes / 4).saturating_sub(self.charged)); + interp.reserve_heap(2 * grow)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(2 * grow); + interp.held_bytes = interp.held_bytes.saturating_add(2 * grow); + self.charged += grow; + } + Ok(()) + } + + fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { + self.make_room(interp, units.len())?; + self.units.extend_from_slice(units); + Ok(()) + } + + /// Appends `units` `count` times. Nothing is written for an empty `units`, however large + /// `count` is. + fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { + if units.is_empty() || count == 0 { + return Ok(()); + } + let more = usize::try_from(count).unwrap_or(usize::MAX).saturating_mul(units.len()); + self.make_room(interp, more)?; + for _ in 0..count { + self.units.extend_from_slice(units); + } + Ok(()) + } + + fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + let units: Vec = s.encode_utf16().collect(); + self.push_units(interp, &units) + } + + /// The string built, whose bytes were charged as it grew. From here on the heap counts them + /// as the string's. + fn finish(self, interp: &mut Interp) -> Value { + interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged); + Value::String(Str::from_units(self.units)) + } +} + +fn require_callable(interp: &mut Interp, v: &Value, what: &str) -> JsResult<()> { + if interp.is_callable(v) { + return Ok(()); + } + Err(interp.type_error(&format!("{what} is not a function"))) +} + fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { let kinds: [(&'static str, NativeFn); 7] = [ ("Error", error_ctor), @@ -177,13 +369,40 @@ fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> J Ok(Value::str(&format!("[object {class}]"))) } +/// The Boolean a `Boolean.prototype` method was called on (sections 15.6.4.2 and 15.6.4.3): a +/// primitive, or the primitive value of a Boolean object. +fn this_boolean(interp: &mut Interp, this: &Value, name: &str) -> JsResult { + let b = match this { + Value::Bool(b) => Some(*b), + Value::Object(o) => match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Boolean(b)) => Some(*b), + _ => None, + }, + _ => None, + }; + b.ok_or_else(|| interp.type_error(&format!("Boolean.prototype.{name} was called on a value that is not a Boolean"))) +} + +/// `Boolean.prototype.toString` (section 15.6.4.2). +fn boolean_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let b = this_boolean(interp, &this, "toString")?; + Ok(Value::str(if b { "true" } else { "false" })) +} + +/// `Boolean.prototype.valueOf` (section 15.6.4.3). +fn boolean_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_boolean(interp, &this, "valueOf").map(Value::Bool) +} + +/// `Object.prototype.toLocaleString` (section 15.2.4.3). fn object_to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { let o = interp.to_object(&this)?; - let f = interp.get_from(o, &key("toString"), &this)?; + let receiver = Value::Object(o); + let f = interp.get_from(o, &key("toString"), &receiver)?; if !interp.is_callable(&f) { return Err(interp.type_error("toString is not a function")); } - interp.call(&f, &this, &[]) + interp.call(&f, &receiver, &[]) } fn object_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { @@ -283,11 +502,40 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; Ok(Value::str(&text)) } +/// `Function.prototype.bind` (section 15.3.4.5). +fn function_bind(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + require_callable(interp, &this, "the value bind was called on")?; + let Some(target) = this.as_object() else { return Err(interp.type_error("the value bind was called on is not a function")) }; + let bound_this = arg(args, 0); + let bound_args: Rc<[Value]> = args.get(1..).unwrap_or_default().into(); + // A host function's `length` is an ordinary property a script can set, so it is converted + // to an integer first, as later editions specify. + let target_length = match interp.get_from(target, &key("length"), &this)? { + Value::Number(n) if n.is_nan() => 0.0, + Value::Number(n) => n.trunc(), + _ => 0.0, + }; + // Adding 0 turns a negative zero into 0. + let length = (target_length - bound_args.len() as f64).max(0.0) + 0.0; + interp.reserve_heap(OBJECT_BYTES + 4 * PROP_BYTES + BOUND_ARG_BYTES * bound_args.len())?; + let proto = interp.intrinsics.function_proto; + let class = Class::Function(Func::Bound { target, this: bound_this, args: bound_args.clone() }); + let f = interp.heap.alloc_object(Object::new(class, Some(proto))); + interp.heap.bytes = interp.heap.bytes.saturating_add(BOUND_ARG_BYTES * bound_args.len()); + interp.define(f, key("length"), Prop::Data { value: Value::Number(length), flags: 0 })?; + let thrower = Some(interp.intrinsics.throw_type_error); + for name in ["caller", "arguments"] { + interp.define(f, key(name), Prop::Accessor { get: thrower, set: thrower, flags: 0 })?; + } + Ok(Value::Object(f)) +} + /// The global `eval` called indirectly (section 15.1.2.1.1): global code in the global /// environment. fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs new file mode 100644 index 000000000..2f81bf766 --- /dev/null +++ b/crates/js/src/builtins/array.rs @@ -0,0 +1,854 @@ +//! `Array` (ECMA-262 5.1 section 15.4): the constructor, `Array.isArray` and every method of +//! `Array.prototype`. The methods are generic: they read `length` and the elements of any object. +//! +//! A method that walks a range of indices visits only the indices that can hold an element when +//! the range is much longer than the object has properties (a sparse array such as +//! `new Array(4294967295)`), so a long empty range costs nothing and no index is allocated for +//! it. The list of indices is made again whenever a script adds an index property to any object +//! while the method runs. Every index visited takes a unit of fuel. + +use super::{ + Builder, ELEMENT_BYTES, arg, constructor, create_at, delete_at, get_at, has_at, length_of, method, native, new_array, object_to_string, put_at, + require_callable, to_integer, +}; +use crate::heap::{Class, NativeKind, ObjId}; +use crate::interp::{Abrupt, Interp, JsResult, strict_eq}; +use crate::value::{Str, Value, to_uint32}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.array_proto; + let array = native(interp, "Array", array_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Array", array, proto)?; + method(interp, array, "isArray", is_array, 1)?; + let methods: [(&'static str, crate::heap::NativeFn, u32); 21] = [ + ("toString", to_string, 0), + ("toLocaleString", to_locale_string, 0), + ("concat", concat, 1), + ("join", join, 1), + ("pop", pop, 0), + ("push", push, 1), + ("reverse", reverse, 0), + ("shift", shift, 0), + ("slice", slice, 2), + ("sort", sort, 1), + ("splice", splice, 2), + ("unshift", unshift, 1), + ("indexOf", index_of, 1), + ("lastIndexOf", last_index_of, 1), + ("every", every, 1), + ("some", some, 1), + ("forEach", for_each, 1), + ("map", map, 1), + ("filter", filter, 1), + ("reduce", reduce, 1), + ("reduceRight", reduce_right, 1), + ]; + for (name, f, length) in methods { + method(interp, proto, name, f, length)?; + } + Ok(()) +} + +// ---- visiting indices ---- + +/// Ranges at most this long are visited index by index. +const DENSE_SPAN: u64 = 4096; + +/// How an index an object holds names an index to visit. +#[derive(Clone, Copy)] +enum Map { + /// The index `p` asks for a visit of `p - shift`. + Shift(i64), + /// The index `p` asks for a visit of `p` and of `mirror - p`. + Mirror(i64), +} + +/// The indices of a range, in order, that an operation must visit. +struct Indices { + obj: ObjId, + /// The range left to visit, `lo` included and `hi` not. + lo: u64, + hi: u64, + ascending: bool, + maps: Vec, + /// The indices left when the range is visited sparsely. + sparse: Option, +} + +struct Sparse { + list: Vec, + pos: usize, + /// [`crate::heap::Heap::index_epoch`] when the list was made. + epoch: u64, +} + +/// The canonical numeric property name `k` stands for, up to 15 digits. +fn numeric_index(k: &[u16]) -> Option { + if k.is_empty() || k.len() > 15 || (k.len() > 1 && k.first() == Some(&u16::from(b'0'))) { + return None; + } + let mut n = 0u64; + for &u in k { + let d = u.checked_sub(u16::from(b'0')).filter(|d| *d <= 9)?; + n = n * 10 + u64::from(d); + } + Some(n) +} + +/// The objects of the prototype chain from `obj`, with how many properties (and String object +/// indices) each holds. +fn chain(interp: &Interp, obj: ObjId) -> Vec<(ObjId, usize)> { + let mut out = Vec::new(); + let mut cur = Some(obj); + while let Some(id) = cur { + let Some(o) = interp.heap.object(id) else { break }; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + out.push((id, o.props.len().saturating_add(string_len))); + cur = o.proto; + } + out +} + +impl Indices { + fn new(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64, ascending: bool, maps: Vec) -> JsResult { + let mut it = Indices { obj, lo, hi, ascending, maps, sparse: None }; + if hi.saturating_sub(lo) > DENSE_SPAN { + let present: usize = chain(interp, obj).iter().map(|(_, n)| *n).fold(0, usize::saturating_add); + if hi - lo > (present as u64).saturating_mul(8) { + it.sparse = Some(it.gather(interp)?); + } + } + Ok(it) + } + + fn up(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult { + Self::new(interp, obj, lo, hi, true, vec![Map::Shift(0)]) + } + + fn down(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult { + Self::new(interp, obj, lo, hi, false, vec![Map::Shift(0)]) + } + + /// Lists the indices left to visit from the properties of the object and its prototypes. + /// Each property takes a unit of fuel. + fn gather(&self, interp: &mut Interp) -> JsResult { + let epoch = interp.heap.index_epoch; + let mut list: Vec = Vec::new(); + for (id, n) in chain(interp, self.obj) { + for _ in 0..n { + interp.use_fuel()?; + } + interp.reserve_heap(n.saturating_mul(8).saturating_mul(self.maps.len() + 1))?; + let Some(o) = interp.heap.object(id) else { continue }; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + let present = (0..string_len as u64).chain(o.props.iter().filter_map(|(k, _)| numeric_index(k))); + for p in present { + for map in &self.maps { + let p = p as i64; + let ks = match *map { + Map::Shift(s) => [Some(p - s), None], + Map::Mirror(m) => [Some(p), Some(m - p)], + }; + for k in ks.into_iter().flatten() { + if k >= 0 && (k as u64) >= self.lo && (k as u64) < self.hi { + list.push(k as u64); + } + } + } + } + } + if self.ascending { + list.sort_unstable(); + } else { + list.sort_unstable_by(|a, b| b.cmp(a)); + } + list.dedup(); + Ok(Sparse { list, pos: 0, epoch }) + } + + /// The next index to visit; it takes a unit of fuel. + fn next(&mut self, interp: &mut Interp) -> JsResult> { + if self.lo >= self.hi { + return Ok(None); + } + if self.sparse.as_ref().is_some_and(|s| s.epoch != interp.heap.index_epoch) { + self.sparse = Some(self.gather(interp)?); + } + let k = match &mut self.sparse { + None if self.ascending => self.lo, + None => self.hi - 1, + Some(s) => { + let Some(&k) = s.list.get(s.pos) else { + self.lo = self.hi; + return Ok(None); + }; + s.pos += 1; + k + } + }; + if self.ascending { + self.lo = k + 1; + } else { + self.hi = k; + } + interp.use_fuel()?; + Ok(Some(k)) + } +} + +/// The element at `k` when the object or its prototypes have one. +fn element(interp: &mut Interp, o: ObjId, k: u64) -> JsResult> { + let k = k as f64; + if !has_at(interp, o, k)? { + return Ok(None); + } + get_at(interp, o, k).map(Some) +} + +/// ToObject of `this`, kept reachable while `f` runs. +fn with_this(interp: &mut Interp, this: &Value, f: impl FnOnce(&mut Interp, ObjId) -> JsResult) -> JsResult { + let o = interp.to_object(this)?; + interp.rooted(&[Value::Object(o)], |interp| f(interp, o)) +} + +/// The start a relative index gives within `0..=len` (`slice` and `splice`). +fn relative(interp: &mut Interp, v: &Value, len: u32) -> JsResult { + let n = to_integer(interp, v)?; + let len = f64::from(len); + Ok(if n < 0.0 { (len + n).max(0.0) } else { n.min(len) } as u64) +} + +fn number(k: u64) -> Value { + Value::Number(k as f64) +} + +// ---- the constructor ---- + +/// `Array(…)` and `new Array(…)` (sections 15.4.1 and 15.4.2). +fn array_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + if let [Value::Number(n)] = args { + let len = to_uint32(*n); + if f64::from(len) != *n { + return Err(interp.throw(crate::interp::ErrorType::Range, "invalid array length")); + } + return new_array(interp, len).map(Value::Object); + } + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + for (i, v) in args.iter().enumerate() { + interp.use_fuel()?; + create_at(interp, a, i as f64, v.clone())?; + } + Ok(Value::Object(a)) + }) +} + +/// `Array.isArray` (section 15.4.3.2). +fn is_array(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let a = arg(args, 0).as_object().and_then(|o| interp.heap.object(o)).is_some_and(|o| matches!(o.class, Class::Array { .. })); + Ok(Value::Bool(a)) +} + +// ---- conversions ---- + +/// `Array.prototype.toString` (section 15.4.4.2). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let join = interp.get_from(o, &crate::heap::key("join"), &Value::Object(o))?; + if interp.is_callable(&join) { + return interp.call(&join, &Value::Object(o), &[]); + } + object_to_string(interp, Value::Object(o), &[], false) + }) +} + +/// Joins the elements `0..len` of `o`, each turned into a string by `item`, with `separator` +/// between them (`,` when it is `undefined`). `length` is read before the separator is converted, +/// and the separator is converted even when `length` is 0 (section 15.4.4.5 steps 2 to 6). A cycle +/// back to an object being joined joins as an empty string. +fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { + if interp.join_stack.contains(&o) { + return Ok(Value::str("")); + } + let len = length_of(interp, o)?; + let sep = match separator { + Value::Undefined => Str::from(","), + v => interp.to_str(v)?, + }; + if len == 0 { + return Ok(Value::str("")); + } + let flat = sep.flat(); + let sep = &flat[..]; + // A result that cannot fit is refused before any element is read. + let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); + if least > (interp.limits.heap_bytes / 4) as u64 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.join_stack.push(o); + let r = join_elements(interp, o, len, sep, item); + interp.join_stack.pop(); + r +} + +fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { + let mut out = Builder::new(); + // Separators written so far: element `k` follows `k` of them. + let mut seps = 0u64; + let mut it = Indices::up(interp, o, 0, u64::from(len))?; + while let Some(k) = it.next(interp)? { + let v = get_at(interp, o, k as f64)?; + let Some(s) = item(interp, v)? else { continue }; + out.push_repeated(interp, sep, k.saturating_sub(seps))?; + seps = k; + out.push_units(interp, &s.flat())?; + } + out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; + Ok(out.finish(interp)) +} + +/// An element as `join` writes it: nothing for `undefined` and `null`. +fn join_item(interp: &mut Interp, v: Value) -> JsResult> { + if v.is_nullish() { + return Ok(None); + } + interp.to_str(&v).map(Some) +} + +/// An element as `toLocaleString` writes it (section 15.4.4.3). +fn locale_item(interp: &mut Interp, v: Value) -> JsResult> { + if v.is_nullish() { + return Ok(None); + } + let e = interp.to_object(&v)?; + let f = interp.get_from(e, &crate::heap::key("toLocaleString"), &Value::Object(e))?; + require_callable(interp, &f, "an element's toLocaleString")?; + let s = interp.call(&f, &Value::Object(e), &[])?; + interp.to_str(&s).map(Some) +} + +/// `Array.prototype.join` (section 15.4.4.5). +fn join(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let separator = arg(args, 0); + with_this(interp, &this, |interp, o| join_with(interp, o, &separator, join_item)) +} + +/// `Array.prototype.toLocaleString` (section 15.4.4.3). +fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| join_with(interp, o, &Value::Undefined, locale_item)) +} + +// ---- methods that change the object ---- + +/// `Array.prototype.concat` (section 15.4.4.4). +fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut n = 0u64; + for e in std::iter::once(Value::Object(o)).chain(args.iter().cloned()) { + interp.use_fuel()?; + let spread = e.as_object().filter(|&id| interp.heap.object(id).is_some_and(|x| matches!(x.class, Class::Array { .. }))); + let Some(e) = spread else { + create_at(interp, a, n as f64, e)?; + n += 1; + continue; + }; + let len = u64::from(length_of(interp, e)?); + let mut it = Indices::up(interp, e, 0, len)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, e, k)? { + create_at(interp, a, (n + k) as f64, v)?; + } + } + n += len; + } + set_length(interp, a, n as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +fn set_length(interp: &mut Interp, o: ObjId, n: f64) -> JsResult<()> { + interp.put(o, crate::heap::key("length"), Value::Number(n), true) +} + +/// `Array.prototype.pop` (section 15.4.4.6). +fn pop(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + if len == 0 { + set_length(interp, o, 0.0)?; + return Ok(Value::Undefined); + } + let last = f64::from(len - 1); + let v = get_at(interp, o, last)?; + interp.rooted(std::slice::from_ref(&v), |interp| { + delete_at(interp, o, last)?; + set_length(interp, o, last) + })?; + Ok(v) + }) +} + +/// `Array.prototype.push` (section 15.4.4.7). +fn push(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let mut n = f64::from(length_of(interp, o)?); + for v in args { + interp.use_fuel()?; + put_at(interp, o, n, v.clone())?; + n += 1.0; + } + set_length(interp, o, n)?; + Ok(Value::Number(n)) + }) +} + +/// `Array.prototype.reverse` (section 15.4.4.8). +fn reverse(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let middle = len / 2; + let mirror = len as i64 - 1; + let mut it = Indices::new(interp, o, 0, middle, true, vec![Map::Mirror(mirror)])?; + while let Some(lower) = it.next(interp)? { + let upper = len - lower - 1; + let lower_value = element(interp, o, lower)?; + let upper_value = interp.rooted(lower_value.as_slice(), |interp| element(interp, o, upper))?; + let mut roots: Vec = lower_value.iter().cloned().collect(); + roots.extend(upper_value.iter().cloned()); + interp.rooted(&roots, |interp| match (lower_value, upper_value) { + (Some(l), Some(u)) => { + put_at(interp, o, lower as f64, u)?; + put_at(interp, o, upper as f64, l) + } + (None, Some(u)) => { + put_at(interp, o, lower as f64, u)?; + delete_at(interp, o, upper as f64) + } + (Some(l), None) => { + delete_at(interp, o, lower as f64)?; + put_at(interp, o, upper as f64, l) + } + (None, None) => Ok(()), + })?; + } + Ok(Value::Object(o)) + }) +} + +/// Moves the element at `from` to `to`, or deletes `to` when there is none at `from`. +fn move_element(interp: &mut Interp, o: ObjId, from: u64, to: u64) -> JsResult<()> { + match element(interp, o, from)? { + Some(v) => put_at(interp, o, to as f64, v), + None => delete_at(interp, o, to as f64), + } +} + +/// `Array.prototype.shift` (section 15.4.4.9). +fn shift(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + if len == 0 { + set_length(interp, o, 0.0)?; + return Ok(Value::Undefined); + } + let first = get_at(interp, o, 0.0)?; + interp.rooted(std::slice::from_ref(&first), |interp| { + let mut it = Indices::new(interp, o, 1, len, true, vec![Map::Shift(0), Map::Shift(-1)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k, k - 1)?; + } + delete_at(interp, o, (len - 1) as f64)?; + set_length(interp, o, (len - 1) as f64) + })?; + Ok(first) + }) +} + +/// `Array.prototype.unshift` (section 15.4.4.13). +fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let count = args.len() as u64; + // With no arguments each element is still read and written back in place (steps 6 and 7). + let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; + let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k - 1, k + count - 1)?; + } + for (j, v) in args.iter().enumerate() { + interp.use_fuel()?; + put_at(interp, o, j as f64, v.clone())?; + } + let n = (len + count) as f64; + set_length(interp, o, n)?; + Ok(Value::Number(n)) + }) +} + +/// `Array.prototype.slice` (section 15.4.4.10). +fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let start = relative(interp, &arg(args, 0), len)?; + let end = match arg(args, 1) { + Value::Undefined => u64::from(len), + v => relative(interp, &v, len)?, + }; + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut it = Indices::up(interp, o, start, end)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? { + create_at(interp, a, (k - start) as f64, v)?; + } + } + set_length(interp, a, end.saturating_sub(start) as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +/// `Array.prototype.splice` (section 15.4.4.12). +fn splice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let start = relative(interp, &arg(args, 0), len)?; + let len = u64::from(len); + let delete_count = match args.get(1) { + // Without a count, everything from the start is deleted, as later editions and + // every implementation do. + None if !args.is_empty() => len - start, + None => 0, + Some(v) => to_integer(interp, v)?.max(0.0).min((len - start) as f64) as u64, + }; + let items = args.get(2..).unwrap_or_default(); + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut it = Indices::up(interp, o, start, start + delete_count)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? { + create_at(interp, a, (k - start) as f64, v)?; + } + } + set_length(interp, a, delete_count as f64)?; + move_tail(interp, o, len, start, delete_count, items.len() as u64)?; + for (j, v) in items.iter().enumerate() { + interp.use_fuel()?; + put_at(interp, o, (start + j as u64) as f64, v.clone())?; + } + set_length(interp, o, (len - delete_count + items.len() as u64) as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +/// Moves the elements after a spliced range so that `item_count` items replace `delete_count` +/// elements at `start` (section 15.4.4.12, steps 12 and 13). +fn move_tail(interp: &mut Interp, o: ObjId, len: u64, start: u64, delete_count: u64, item_count: u64) -> JsResult<()> { + let (dc, ic) = (delete_count as i64, item_count as i64); + if item_count < delete_count { + let mut it = Indices::new(interp, o, start, len - delete_count, true, vec![Map::Shift(dc), Map::Shift(ic)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k + delete_count, k + item_count)?; + } + let mut it = Indices::down(interp, o, len - delete_count + item_count, len)?; + while let Some(k) = it.next(interp)? { + delete_at(interp, o, k as f64)?; + } + } else if item_count > delete_count { + let mut it = Indices::new(interp, o, start + 1, len - delete_count + 1, false, vec![Map::Shift(dc - 1), Map::Shift(ic - 1)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k + delete_count - 1, k + item_count - 1)?; + } + } + Ok(()) +} + +// ---- sort ---- + +/// A stable merge sort whose comparison may fail. `greater(a, b)` says whether `a` sorts after +/// `b`; each comparison takes a unit of fuel. +fn merge_sort(interp: &mut Interp, items: &mut Vec, mut greater: impl FnMut(&mut Interp, &T, &T) -> JsResult) -> JsResult<()> { + let n = items.len(); + let mut width = 1; + let mut buf: Vec = Vec::with_capacity(n); + while width < n { + buf.clear(); + let mut lo = 0; + while lo < n { + let mid = (lo + width).min(n); + let hi = (lo + 2 * width).min(n); + let (mut i, mut j) = (lo, mid); + while i < mid && j < hi { + let (Some(a), Some(b)) = (items.get(i), items.get(j)) else { break }; + interp.use_fuel()?; + if greater(interp, a, b)? { + buf.push(b.clone()); + j += 1; + } else { + buf.push(a.clone()); + i += 1; + } + } + buf.extend(items.get(i..mid).unwrap_or_default().iter().cloned()); + buf.extend(items.get(j..hi).unwrap_or_default().iter().cloned()); + lo = hi; + } + std::mem::swap(items, &mut buf); + width *= 2; + } + Ok(()) +} + +/// `Array.prototype.sort` (section 15.4.4.11). The elements are read, sorted with `undefined` +/// after every other value, written back from index 0, and the indices after them are deleted. +fn sort(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let compare = arg(args, 0); + if !matches!(compare, Value::Undefined) { + require_callable(interp, &compare, "the comparison")?; + } + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let mark = interp.temp_roots.len(); + let r = sort_elements(interp, o, len, &compare); + interp.temp_roots.truncate(mark); + r?; + Ok(Value::Object(o)) + }) +} + +fn sort_elements(interp: &mut Interp, o: ObjId, len: u64, compare: &Value) -> JsResult<()> { + let mut values = Vec::new(); + let mut undefined = 0u64; + let mut it = Indices::up(interp, o, 0, len)?; + while let Some(k) = it.next(interp)? { + match element(interp, o, k)? { + Some(Value::Undefined) => undefined += 1, + Some(v) => { + interp.temp_roots.push(v.clone()); + interp.reserve_heap(ELEMENT_BYTES)?; + values.push(v); + } + None => {} + } + } + if matches!(compare, Value::Undefined) { + let mut keyed = Vec::with_capacity(values.len()); + for v in values { + interp.use_fuel()?; + let s = interp.to_str(&v)?; + // Flattening a concatenation makes a new buffer, so it is charged. A key that is + // not an element is rooted so that collections count it. + let rope = s.buffer_addr().is_none(); + let bytes = crate::heap::string_bytes(s.len()); + if rope { + interp.reserve_heap(bytes)?; + } + let units = s.flat(); + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + if !matches!(v, Value::String(_)) { + interp.temp_roots.push(Value::String(s)); + } + keyed.push((units, v)); + } + merge_sort(interp, &mut keyed, |_, a, b| Ok(a.0 > b.0))?; + values = keyed.into_iter().map(|(_, v)| v).collect(); + } else { + merge_sort(interp, &mut values, |interp, a, b| { + let r = interp.call(compare, &Value::Undefined, &[a.clone(), b.clone()])?; + Ok(interp.to_number(&r)? > 0.0) + })?; + } + let mut i = 0u64; + for v in values { + interp.use_fuel()?; + put_at(interp, o, i as f64, v)?; + i += 1; + } + for _ in 0..undefined { + interp.use_fuel()?; + put_at(interp, o, i as f64, Value::Undefined)?; + i += 1; + } + let mut it = Indices::down(interp, o, i, len)?; + while let Some(k) = it.next(interp)? { + delete_at(interp, o, k as f64)?; + } + Ok(()) +} + +// ---- searching ---- + +/// `Array.prototype.indexOf` (section 15.4.4.14). +fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = f64::from(length_of(interp, o)?); + if len == 0.0 { + return Ok(Value::Number(-1.0)); + } + let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { 0.0 }; + if n >= len { + return Ok(Value::Number(-1.0)); + } + let start = if n >= 0.0 { n } else { (len + n).max(0.0) }; + let target = arg(args, 0); + let mut it = Indices::up(interp, o, start as u64, len as u64)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? + && strict_eq(&v, &target) + { + return Ok(number(k)); + } + } + Ok(Value::Number(-1.0)) + }) +} + +/// `Array.prototype.lastIndexOf` (section 15.4.4.15). +fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = f64::from(length_of(interp, o)?); + if len == 0.0 { + return Ok(Value::Number(-1.0)); + } + let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { len - 1.0 }; + let from = if n >= 0.0 { n.min(len - 1.0) } else { len + n }; + if from < 0.0 { + return Ok(Value::Number(-1.0)); + } + let target = arg(args, 0); + let mut it = Indices::down(interp, o, 0, from as u64 + 1)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? + && strict_eq(&v, &target) + { + return Ok(number(k)); + } + } + Ok(Value::Number(-1.0)) + }) +} + +// ---- iteration ---- + +/// What an iteration method does with each callback result. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Each { + Every, + Some, + /// `forEach`. + Visit, + Map, + Filter, +} + +/// `every`, `some`, `forEach`, `map` and `filter` (sections 15.4.4.16 to 15.4.4.20). +fn iterate(interp: &mut Interp, this: Value, args: &[Value], kind: Each) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let callback = arg(args, 0); + require_callable(interp, &callback, "the callback")?; + let this_arg = arg(args, 1); + let a = match kind { + Each::Map => Some(new_array(interp, len)?), + Each::Filter => Some(new_array(interp, 0)?), + _ => None, + }; + let roots: Vec = a.map(Value::Object).into_iter().collect(); + interp.rooted(&roots, |interp| { + let mut to = 0u64; + let mut it = Indices::up(interp, o, 0, u64::from(len))?; + while let Some(k) = it.next(interp)? { + let Some(v) = element(interp, o, k)? else { continue }; + let r = interp.call(&callback, &this_arg, &[v.clone(), number(k), Value::Object(o)])?; + match (kind, a) { + (Each::Every, _) if !r.truthy() => return Ok(Value::Bool(false)), + (Each::Some, _) if r.truthy() => return Ok(Value::Bool(true)), + (Each::Map, Some(a)) => create_at(interp, a, k as f64, r)?, + (Each::Filter, Some(a)) if r.truthy() => { + create_at(interp, a, to as f64, v)?; + to += 1; + } + _ => {} + } + } + Ok(match (kind, a) { + (Each::Every, _) => Value::Bool(true), + (Each::Some, _) => Value::Bool(false), + (_, Some(a)) => Value::Object(a), + _ => Value::Undefined, + }) + }) + }) +} + +fn every(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Every) +} + +fn some(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Some) +} + +fn for_each(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Visit) +} + +fn map(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Map) +} + +fn filter(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Filter) +} + +/// `reduce` and `reduceRight` (sections 15.4.4.21 and 15.4.4.22). +fn fold(interp: &mut Interp, this: Value, args: &[Value], right: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let callback = arg(args, 0); + require_callable(interp, &callback, "the callback")?; + let mut it = Indices::new(interp, o, 0, len, !right, vec![Map::Shift(0)])?; + let slot = interp.temp_roots.len(); + interp.temp_roots.push(Value::Undefined); + // The accumulator is kept in a root slot while the elements are read. + let keep = |interp: &mut Interp, acc: &Value| { + if let Some(s) = interp.temp_roots.get_mut(slot) { + *s = acc.clone(); + } + }; + let r = (|| { + let mut acc = match args.get(1) { + Some(v) => v.clone(), + None => loop { + let Some(k) = it.next(interp)? else { return Err(interp.type_error("reduce of an empty array with no initial value")) }; + if let Some(v) = element(interp, o, k)? { + break v; + } + }, + }; + keep(interp, &acc); + while let Some(k) = it.next(interp)? { + let Some(v) = element(interp, o, k)? else { continue }; + acc = interp.call(&callback, &Value::Undefined, &[acc, v, number(k), Value::Object(o)])?; + keep(interp, &acc); + } + Ok(acc) + })(); + interp.temp_roots.truncate(slot); + r + }) +} + +fn reduce(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + fold(interp, this, args, false) +} + +fn reduce_right(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + fold(interp, this, args, true) +} diff --git a/crates/js/src/builtins/global.rs b/crates/js/src/builtins/global.rs new file mode 100644 index 000000000..182f3aad5 --- /dev/null +++ b/crates/js/src/builtins/global.rs @@ -0,0 +1,364 @@ +//! The global functions (ECMA-262 5.1 section 15.1.2 and 15.1.3): `parseInt`, `parseFloat`, +//! `isNaN`, `isFinite` and the URI functions, and Annex B's `escape` and `unescape` (sections +//! B.2.1 and B.2.2). Each code unit read takes a unit of fuel, and the strings they build are +//! checked against the heap limit as they grow. + +use super::{Builder, arg, method}; +use crate::heap::NativeFn; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::value::{Value, is_str_space, to_int32}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let global = interp.global; + let functions: [(&'static str, NativeFn, u32); 10] = [ + ("parseInt", parse_int, 2), + ("parseFloat", parse_float, 1), + ("isNaN", is_nan, 1), + ("isFinite", is_finite, 1), + ("decodeURI", decode_uri, 1), + ("decodeURIComponent", decode_uri_component, 1), + ("encodeURI", encode_uri, 1), + ("encodeURIComponent", encode_uri_component, 1), + ("escape", escape, 1), + ("unescape", unescape, 1), + ]; + for (name, f, length) in functions { + method(interp, global, name, f, length)?; + } + Ok(()) +} + +/// `s` after its leading white space and line terminators. +fn trim_start(s: &[u16]) -> &[u16] { + s.get(s.iter().position(|&u| !is_str_space(u)).unwrap_or(s.len())..).unwrap_or_default() +} + +/// ToString of the first argument, as code units, with each one taking a unit of fuel. A +/// concatenation is flattened into a new buffer, which is charged to the heap. +pub(super) fn units_of(interp: &mut Interp, args: &[Value]) -> JsResult> { + let s = interp.to_str(&arg(args, 0))?; + for _ in 0..s.len() { + interp.use_fuel()?; + } + let rope = s.buffer_addr().is_none(); + let bytes = crate::heap::string_bytes(s.len()); + if rope { + let mark = interp.temp_roots.len(); + interp.temp_roots.push(Value::String(s.clone())); + let reserved = interp.reserve_heap(bytes); + interp.temp_roots.truncate(mark); + reserved?; + } + let units = s.flat(); + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + Ok(units) +} + +fn digit_value(u: u16) -> Option { + char::from_u32(u32::from(u)).and_then(|c| c.to_digit(36)) +} + +/// `parseInt` (section 15.1.2.2). +fn parse_int(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let radix = to_int32(interp.to_number(&arg(args, 1))?); + let mut rest = trim_start(&s); + let sign = match rest.first() { + Some(&u) if u == u16::from(b'-') => -1.0, + _ => 1.0, + }; + if matches!(rest.first(), Some(&u) if u == u16::from(b'-') || u == u16::from(b'+')) { + rest = rest.get(1..).unwrap_or_default(); + } + let mut r = radix as u32; + let mut strip_prefix = true; + if radix != 0 { + if !(2..=36).contains(&radix) { + return Ok(Value::Number(f64::NAN)); + } + strip_prefix = radix == 16; + } else { + r = 10; + } + if strip_prefix + && rest.len() >= 2 + && rest.first() == Some(&u16::from(b'0')) + && matches!(rest.get(1), Some(&u) if u == u16::from(b'x') || u == u16::from(b'X')) + { + rest = rest.get(2..).unwrap_or_default(); + r = 16; + } + let n = rest.iter().map_while(|&u| digit_value(u).filter(|&d| d < r)).count(); + let prefix = rest.get(..n).unwrap_or_default(); + if prefix.is_empty() { + return Ok(Value::Number(f64::NAN)); + } + let digits = prefix.iter().filter_map(|&u| digit_value(u)); + let value = match r { + 10 => parse_decimal(interp, prefix)?, + 2 | 4 | 8 | 16 | 32 => crate::chars::radix_value(digits, r).unwrap_or(f64::NAN), + // Other radices may be approximated (section 15.1.2.2, step 13). + _ => digits.fold(0.0, |n, d| n * f64::from(r) + f64::from(d)), + }; + Ok(Value::Number(sign * value)) +} + +/// The number that `text`, ASCII decimal digits with an optional fraction and exponent, stands +/// for. The text is copied into a string of one byte per code unit for parsing, and that copy is +/// checked against the heap limit first. +fn parse_decimal(interp: &mut Interp, text: &[u16]) -> JsResult { + interp.reserve_heap(text.len())?; + let mut ascii = String::with_capacity(text.len()); + ascii.extend(text.iter().filter_map(|&u| char::from_u32(u32::from(u)))); + Ok(ascii.parse::().unwrap_or(f64::NAN)) +} + +/// The length of the longest prefix of `s` that is a StrDecimalLiteral without its sign and +/// without `Infinity`. +fn decimal_prefix(s: &[u16]) -> usize { + let is_digit = |i: usize| s.get(i).is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); + let mut i = 0; + while is_digit(i) { + i += 1; + } + let mut mantissa = i; + if s.get(i) == Some(&u16::from(b'.')) { + let mut j = i + 1; + while is_digit(j) { + j += 1; + } + mantissa += j - i - 1; + if mantissa > 0 { + i = j; + } + } + if mantissa == 0 { + return 0; + } + if matches!(s.get(i), Some(&u) if u == u16::from(b'e') || u == u16::from(b'E')) { + let mut j = i + 1; + if matches!(s.get(j), Some(&u) if u == u16::from(b'+') || u == u16::from(b'-')) { + j += 1; + } + let digits_start = j; + while is_digit(j) { + j += 1; + } + if j > digits_start { + i = j; + } + } + i +} + +/// `parseFloat` (section 15.1.2.3). +fn parse_float(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut rest = trim_start(&s); + let mut sign = 1.0; + if let Some(&u) = rest.first() + && (u == u16::from(b'-') || u == u16::from(b'+')) + { + if u == u16::from(b'-') { + sign = -1.0; + } + rest = rest.get(1..).unwrap_or_default(); + } + let infinity: Vec = "Infinity".encode_utf16().collect(); + if rest.starts_with(&infinity) { + return Ok(Value::Number(sign * f64::INFINITY)); + } + let n = decimal_prefix(rest); + if n == 0 { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number(sign * parse_decimal(interp, rest.get(..n).unwrap_or_default())?)) +} + +/// `isNaN` (section 15.1.2.4). +fn is_nan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_nan())) +} + +/// `isFinite` (section 15.1.2.5). +fn is_finite(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_finite())) +} + +// ---- URIs ---- + +const URI_RESERVED: &str = ";/?:@&=+$,"; +const URI_MARK: &str = "-_.!~*'()"; + +fn in_set(u: u16, extra: &str) -> bool { + u < 0x80 && extra.as_bytes().contains(&(u as u8)) +} + +fn is_unreserved(u: u16) -> bool { + u < 0x80 && ((u as u8).is_ascii_alphanumeric() || in_set(u, URI_MARK)) +} + +fn uri_error(interp: &mut Interp, why: &str) -> crate::interp::Abrupt { + interp.throw(ErrorType::Uri, why) +} + +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +fn push_byte(out: &mut Builder, interp: &mut Interp, b: u8) -> JsResult<()> { + let units = [u16::from(b'%'), u16::from(HEX[usize::from(b >> 4)]), u16::from(HEX[usize::from(b & 15)])]; + out.push_units(interp, &units) +} + +/// Encode (section 15.1.3): every code unit outside the unescaped set as the `%XX` escapes of +/// its UTF-8 bytes. +fn encode(interp: &mut Interp, args: &[Value], unescaped: fn(u16) -> bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + while let Some(&c) = s.get(k) { + if unescaped(c) { + out.push_units(interp, &[c])?; + k += 1; + continue; + } + let v = match c { + 0xDC00..=0xDFFF => return Err(uri_error(interp, "a lone trailing surrogate cannot be encoded")), + 0xD800..=0xDBFF => match s.get(k + 1) { + Some(&d) if (0xDC00..=0xDFFF).contains(&d) => { + k += 1; + (u32::from(c) - 0xD800) * 0x400 + (u32::from(d) - 0xDC00) + 0x10000 + } + _ => return Err(uri_error(interp, "a lone leading surrogate cannot be encoded")), + }, + _ => u32::from(c), + }; + let mut buf = [0u8; 4]; + let Some(ch) = char::from_u32(v) else { return Err(uri_error(interp, "the string holds a code point that cannot be encoded")) }; + for &b in ch.encode_utf8(&mut buf).as_bytes() { + push_byte(&mut out, interp, b)?; + } + k += 1; + } + Ok(out.finish(interp)) +} + +fn hex_byte(s: &[u16], at: usize) -> Option { + let hi = crate::chars::hex_value(u32::from(*s.get(at)?))?; + let lo = crate::chars::hex_value(u32::from(*s.get(at + 1)?))?; + u8::try_from(hi * 16 + lo).ok() +} + +/// Decode (section 15.1.3): each `%XX` escape sequence of a UTF-8 character as the character, +/// except an escaped character in `reserved`, which stays escaped. +fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + while let Some(&c) = s.get(k) { + if c != u16::from(b'%') { + out.push_units(interp, &[c])?; + k += 1; + continue; + } + let start = k; + let Some(b) = hex_byte(&s, k + 1) else { return Err(uri_error(interp, "a % is not followed by two hexadecimal digits")) }; + k += 2; + if b & 0x80 == 0 { + let c = u16::from(b); + if in_set(c, reserved) { + out.push_units(interp, s.get(start..=k).unwrap_or_default())?; + } else { + out.push_units(interp, &[c])?; + } + k += 1; + continue; + } + let n = b.leading_ones() as usize; + if n == 1 || n > 4 { + return Err(uri_error(interp, "an escape sequence is not the start of a UTF-8 character")); + } + let mut octets = vec![b]; + for _ in 1..n { + k += 1; + let next = if s.get(k) == Some(&u16::from(b'%')) { hex_byte(&s, k + 1) } else { None }; + let Some(b) = next.filter(|b| b & 0xC0 == 0x80) else { return Err(uri_error(interp, "an escaped UTF-8 character is incomplete")) }; + k += 2; + octets.push(b); + } + let Some(ch) = std::str::from_utf8(&octets).ok().and_then(|t| t.chars().next()) else { + return Err(uri_error(interp, "an escape sequence is not valid UTF-8")); + }; + let mut units = [0u16; 2]; + out.push_units(interp, ch.encode_utf16(&mut units))?; + k += 1; + } + Ok(out.finish(interp)) +} + +fn encode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + encode(interp, args, |u| is_unreserved(u) || in_set(u, URI_RESERVED) || u == u16::from(b'#')) +} + +fn encode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + encode(interp, args, is_unreserved) +} + +fn decode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + decode(interp, args, ";/?:@&=+$,#") +} + +fn decode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + decode(interp, args, "") +} + +// ---- Annex B ---- + +/// `escape` (section B.2.1). +fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + for &u in s.iter() { + if u < 0x80 && ((u as u8).is_ascii_alphanumeric() || b"@*_+-./".contains(&(u as u8))) { + out.push_units(interp, &[u])?; + } else if u < 256 { + push_byte(&mut out, interp, u as u8)?; + } else { + let hex = |shift: u16| u16::from(HEX[usize::from((u >> shift) & 15)]); + out.push_units(interp, &[u16::from(b'%'), u16::from(b'u'), hex(12), hex(8), hex(4), hex(0)])?; + } + } + Ok(out.finish(interp)) +} + +/// `unescape` (section B.2.2). +fn unescape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + let hex4 = |at: usize| -> Option { + let mut v = 0u16; + for i in 0..4 { + v = v * 16 + u16::try_from(crate::chars::hex_value(u32::from(*s.get(at + i)?))?).ok()?; + } + Some(v) + }; + while let Some(&c) = s.get(k) { + let mut unit = c; + if c == u16::from(b'%') { + if s.get(k + 1) == Some(&u16::from(b'u')) + && let Some(v) = hex4(k + 2) + { + unit = v; + k += 5; + } else if let Some(b) = hex_byte(&s, k + 1) { + unit = u16::from(b); + k += 2; + } + } + out.push_units(interp, &[unit])?; + k += 1; + } + Ok(out.finish(interp)) +} diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs new file mode 100644 index 000000000..4375303fe --- /dev/null +++ b/crates/js/src/builtins/json.rs @@ -0,0 +1,734 @@ +//! The `JSON` object (ECMA-262 5.1 section 15.12): `parse` with a reviver and `stringify` with a +//! replacer function or property list and indentation. +//! +//! Both work without recursion. `parse` reads nested arrays and objects with an explicit stack and +//! walks them for the reviver with another, and `stringify` writes nested values from a stack of +//! the objects it is inside, so nesting is limited only by the heap. Each code unit of the text +//! parsed, each property visited and each value written takes a unit of fuel; strings, arrays and +//! objects are checked against the heap limit before they are made, and the text `stringify` +//! writes is checked as it grows. + +use std::collections::HashSet; + +use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; +use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; +use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; +use crate::value::{Str, Value, number_to_str}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.object_proto; + let json = interp.heap.alloc_object(Object::new(Class::Json, Some(proto))); + method(interp, json, "parse", parse, 2)?; + method(interp, json, "stringify", stringify, 3)?; + let global = interp.global; + data(interp, global, "JSON", Value::Object(json), crate::heap::HIDDEN) +} + +// ---- parse ---- + +/// A container being read, with the property name its next value goes under. +enum Open { + Array { obj: ObjId, next: u32 }, + Object { obj: ObjId, name: Option }, +} + +struct Parser<'a> { + s: &'a [u16], + pos: usize, +} + +const QUOTE: u16 = b'"' as u16; + +impl Parser<'_> { + fn peek(&self) -> Option { + self.s.get(self.pos).copied() + } + + fn skip_space(&mut self) { + while matches!(self.peek(), Some(0x09 | 0x0A | 0x0D | 0x20)) { + self.pos += 1; + } + } + + fn eat(&mut self, c: u8) -> bool { + if self.peek() == Some(u16::from(c)) { + self.pos += 1; + return true; + } + false + } + + fn word(&mut self, w: &str) -> bool { + let units: Vec = w.encode_utf16().collect(); + if self.s.get(self.pos..).is_some_and(|rest| rest.starts_with(&units)) { + self.pos += units.len(); + return true; + } + false + } + + /// A JSONString's characters (section 15.12.1.1), after its opening quote. + fn string(&mut self) -> Result, &'static str> { + let mut out = Vec::new(); + loop { + let Some(c) = self.peek() else { return Err("a string is not closed") }; + self.pos += 1; + match c { + QUOTE => return Ok(out), + 0x5C => { + let Some(e) = self.peek() else { return Err("a string is not closed") }; + self.pos += 1; + let unit = match u8::try_from(e).unwrap_or(0) { + b'"' => QUOTE, + b'\\' => 0x5C, + b'/' => 0x2F, + b'b' => 0x08, + b'f' => 0x0C, + b'n' => 0x0A, + b'r' => 0x0D, + b't' => 0x09, + b'u' => { + let mut v = 0u32; + for _ in 0..4 { + let Some(d) = self.peek().and_then(|u| crate::chars::hex_value(u32::from(u))) else { + return Err("a \\u escape needs four hexadecimal digits"); + }; + self.pos += 1; + v = v * 16 + d; + } + u16::try_from(v).unwrap_or(0) + } + _ => return Err("a string holds an escape sequence JSON does not have"), + }; + out.push(unit); + } + 0..=0x1F => return Err("a string holds a control character"), + _ => out.push(c), + } + } + } + + /// A JSONNumber (section 15.12.1.1). + fn number(&mut self) -> Result { + let start = self.pos; + self.eat(b'-'); + let digit = |p: &Self| p.peek().is_some_and(|u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); + if self.eat(b'0') { + } else if digit(self) { + while digit(self) { + self.pos += 1; + } + } else { + return Err("a number has no digits"); + } + if self.eat(b'.') { + if !digit(self) { + return Err("a number's fraction has no digits"); + } + while digit(self) { + self.pos += 1; + } + } + if self.eat(b'e') || self.eat(b'E') { + if !self.eat(b'+') { + self.eat(b'-'); + } + if !digit(self) { + return Err("a number's exponent has no digits"); + } + while digit(self) { + self.pos += 1; + } + } + let text = String::from_utf16_lossy(self.s.get(start..self.pos).unwrap_or_default()); + text.parse::().map_err(|_| "a number cannot be read") + } +} + +/// What the parser read at a value position. +enum Token { + Value(Value), + OpenArray, + OpenObject, +} + +fn syntax(interp: &mut Interp, why: &str, at: usize) -> crate::interp::Abrupt { + interp.throw(ErrorType::Syntax, &format!("JSON.parse: {why} (at offset {at})")) +} + +/// `JSON.parse` (section 15.12.2). +fn parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let source = Value::String(interp.to_str(&arg(args, 0))?); + let mark = interp.temp_roots.len(); + // The text stays rooted for the whole parse, so a collection still counts it. + interp.temp_roots.push(source.clone()); + let parsed = super::global::units_of(interp, std::slice::from_ref(&source)).and_then(|text| parse_text(interp, &text)); + interp.temp_roots.truncate(mark); + drop(source); + let v = parsed?; + let reviver = arg(args, 1); + if !interp.is_callable(&reviver) { + return Ok(v); + } + interp.temp_roots.push(v.clone()); + let r = revive(interp, v, &reviver); + interp.temp_roots.truncate(mark); + r +} + +/// Reads a value at the parser's position: a primitive, or the start of an array or object. +fn token(interp: &mut Interp, p: &mut Parser<'_>) -> JsResult { + p.skip_space(); + let at = p.pos; + let t = match p.peek() { + Some(QUOTE) => { + p.pos += 1; + let units = p.string().map_err(|why| syntax(interp, why, at))?; + Token::Value(new_string(interp, units)?) + } + Some(0x5B) => { + p.pos += 1; + Token::OpenArray + } + Some(0x7B) => { + p.pos += 1; + Token::OpenObject + } + _ if p.word("true") => Token::Value(Value::Bool(true)), + _ if p.word("false") => Token::Value(Value::Bool(false)), + _ if p.word("null") => Token::Value(Value::Null), + Some(u) if u == u16::from(b'-') || (u16::from(b'0')..=u16::from(b'9')).contains(&u) => { + Token::Value(Value::Number(p.number().map_err(|why| syntax(interp, why, at))?)) + } + Some(_) => return Err(syntax(interp, "unexpected character", at)), + None => return Err(syntax(interp, "unexpected end of text", at)), + }; + Ok(t) +} + +/// Reads the property name of an object member and its colon, or `None` at the closing brace +/// of an empty object. +fn member_name(interp: &mut Interp, p: &mut Parser<'_>, first: bool) -> JsResult> { + p.skip_space(); + if first && p.eat(b'}') { + return Ok(None); + } + let at = p.pos; + if !p.eat(b'"') { + return Err(syntax(interp, "a property name must be a string", at)); + } + let units = p.string().map_err(|why| syntax(interp, why, at))?; + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + // The name is counted while it waits for its value; `parse_text` releases it before `define` + // accounts for it, and the native call's return releases it on every other path. + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + interp.held_bytes = interp.held_bytes.saturating_add(bytes); + p.skip_space(); + if !p.eat(b':') { + return Err(syntax(interp, "a property name must be followed by ':'", p.pos)); + } + Ok(Some(units.into())) +} + +fn parse_text(interp: &mut Interp, text: &[u16]) -> JsResult { + let mut p = Parser { s: text, pos: 0 }; + let mut stack: Vec = Vec::new(); + // The next value to read: the first of an array or object, or one after a comma. + let mut value = token(interp, &mut p)?; + loop { + // Open a container, or place a finished value into the container it belongs to. + let mut done = match value { + Token::OpenArray => { + let a = new_array(interp, 0)?; + interp.temp_roots.push(Value::Object(a)); + p.skip_space(); + if p.eat(b']') { + Some(Value::Object(a)) + } else { + stack.push(Open::Array { obj: a, next: 0 }); + value = token(interp, &mut p)?; + continue; + } + } + Token::OpenObject => { + interp.reserve_heap(OBJECT_BYTES)?; + let o = interp.new_object(); + interp.temp_roots.push(Value::Object(o)); + match member_name(interp, &mut p, true)? { + None => Some(Value::Object(o)), + Some(name) => { + stack.push(Open::Object { obj: o, name: Some(name) }); + value = token(interp, &mut p)?; + continue; + } + } + } + Token::Value(v) => Some(v), + }; + // Close every container that the finished value completes. + while let Some(v) = done.take() { + let Some(top) = stack.last_mut() else { + p.skip_space(); + if p.pos < text.len() { + return Err(syntax(interp, "unexpected text after the value", p.pos)); + } + return Ok(v); + }; + match top { + Open::Array { obj, next } => { + let (obj, i) = (*obj, *next); + *next = next.saturating_add(1); + interp.rooted(std::slice::from_ref(&v), |i| i.reserve_heap(ELEMENT_BYTES))?; + interp.define(obj, key_of(f64::from(i)), Prop::Data { value: v, flags: OPEN })?; + p.skip_space(); + if p.eat(b',') { + break; + } + if !p.eat(b']') { + return Err(syntax(interp, "expected ',' or ']'", p.pos)); + } + stack.pop(); + done = Some(Value::Object(obj)); + } + Open::Object { obj, name } => { + let obj = *obj; + let Some(k) = name.take() else { return Err(syntax(interp, "a member has no name", p.pos)) }; + interp.rooted(std::slice::from_ref(&v), |i| i.reserve_heap(ELEMENT_BYTES))?; + let held = string_bytes(k.len()); + interp.heap.bytes = interp.heap.bytes.saturating_sub(held); + interp.held_bytes = interp.held_bytes.saturating_sub(held); + interp.define(obj, k, Prop::Data { value: v, flags: OPEN })?; + p.skip_space(); + if p.eat(b',') { + let k = member_name(interp, &mut p, false)?; + if let Some(Open::Object { name, .. }) = stack.last_mut() { + *name = k; + } + break; + } + if !p.eat(b'}') { + return Err(syntax(interp, "expected ',' or '}'", p.pos)); + } + stack.pop(); + done = Some(Value::Object(obj)); + } + } + } + value = token(interp, &mut p)?; + } +} + +/// A holder being walked for the reviver (section 15.12.2, Walk). +struct Walk { + holder: ObjId, + name: Key, + val: Value, + keys: Vec, + next: usize, +} + +/// Starts walking `holder[name]`: reads the value and, for an array or object, the names of its +/// elements or own enumerable properties. The value is pushed onto the temporary roots. +fn enter(interp: &mut Interp, holder: ObjId, name: Key) -> JsResult { + let val = interp.get_from(holder, &name, &Value::Object(holder))?; + interp.temp_roots.push(val.clone()); + let mut keys = Vec::new(); + if let Value::Object(o) = val { + if interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })) { + let len = length_of(interp, o)?; + interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?; + for i in 0..len { + interp.use_fuel()?; + keys.push(key_of(f64::from(i))); + } + } else { + for k in interp.own_keys(o)? { + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + keys.push(k); + } + } + } + } + Ok(Walk { holder, name, val, keys, next: 0 }) +} + +/// Walks the parsed value bottom-up, calling the reviver for each property and replacing or +/// deleting the property with what it returns. +fn revive(interp: &mut Interp, v: Value, reviver: &Value) -> JsResult { + interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?; + let root = interp.new_object(); + interp.temp_roots.push(Value::Object(root)); + interp.define(root, key(""), Prop::Data { value: v, flags: OPEN })?; + let mut stack = vec![enter(interp, root, key(""))?]; + let mut ret: Option = None; + loop { + let Some(top) = stack.last_mut() else { return Ok(Value::Undefined) }; + if let (Some(r), Value::Object(o)) = (ret.take(), &top.val) { + let o = *o; + let Some(k) = top.next.checked_sub(1).and_then(|i| top.keys.get(i)).cloned() else { return Ok(Value::Undefined) }; + if matches!(r, Value::Undefined) { + interp.delete(o, &k, false)?; + } else { + interp.rooted(std::slice::from_ref(&r), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.define_own_property(o, k, &Desc::data(r.clone(), OPEN), false) + })?; + } + } + if let (Some(k), Value::Object(o)) = (top.keys.get(top.next).cloned(), &top.val) { + let o = *o; + top.next += 1; + interp.use_fuel()?; + stack.push(enter(interp, o, k)?); + continue; + } + let Some(done) = stack.pop() else { return Ok(Value::Undefined) }; + let name = Value::String(Str::from_rc(done.name)); + let r = interp.call(reviver, &Value::Object(done.holder), &[name, done.val])?; + interp.temp_roots.pop(); + if stack.is_empty() { + return Ok(r); + } + ret = Some(r); + } +} + +// ---- stringify ---- + +/// The settings of one `stringify` call. +struct Settings { + replacer: Option, + properties: Option>, + gap: Vec, +} + +/// A value as `stringify` writes it, after `toJSON`, the replacer and unwrapping. +enum Item { + Skip, + Primitive(Value), + Container(ObjId), +} + +/// An object or array being written. +struct Writing { + obj: ObjId, + keys: Vec, + array: bool, + next: usize, + count: usize, + /// The indentation outside it. + stepback: usize, +} + +struct Writer { + out: Builder, + indent: Vec, + stack: Vec, + open: HashSet, +} + +/// `JSON.stringify` (section 15.12.3). +fn stringify(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mark = interp.temp_roots.len(); + let r = stringify_rooted(interp, args); + interp.temp_roots.truncate(mark); + r +} + +fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { + let settings = settings(interp, &arg(args, 1), &arg(args, 2))?; + interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?; + let wrapper = interp.new_object(); + interp.temp_roots.push(Value::Object(wrapper)); + interp.define(wrapper, key(""), Prop::Data { value: arg(args, 0), flags: OPEN })?; + let mut w = Writer { out: Builder::new(), indent: Vec::new(), stack: Vec::new(), open: HashSet::new() }; + match resolve(interp, &settings, wrapper, &key(""))? { + Item::Skip => return Ok(Value::Undefined), + Item::Primitive(v) => write_primitive(interp, &mut w.out, &v)?, + Item::Container(o) => { + interp.temp_roots.push(Value::Object(o)); + open(interp, &settings, &mut w, o)?; + } + } + while !w.stack.is_empty() { + step(interp, &settings, &mut w)?; + } + Ok(w.out.finish(interp)) +} + +/// Reads the replacer and the space argument (section 15.12.3, steps 4 to 8). +fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult { + let mut s = Settings { replacer: None, properties: None, gap: Vec::new() }; + if let Value::Object(r) = replacer { + if interp.is_callable(replacer) { + s.replacer = Some(replacer.clone()); + } else if interp.heap.object(*r).is_some_and(|o| matches!(o.class, Class::Array { .. })) { + s.properties = Some(property_list(interp, *r)?); + } + } + let space = unwrap_primitive(interp, space, false)?.unwrap_or_else(|| space.clone()); + match space { + Value::Number(_) => { + let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; + s.gap = vec![u16::from(b' '); n]; + } + Value::String(t) => s.gap = t.flat().iter().take(10).copied().collect(), + _ => {} + } + Ok(s) +} + +/// The property names a replacer array lists, without repeats (section 15.12.3, step 4.b). +fn property_list(interp: &mut Interp, r: ObjId) -> JsResult> { + let len = length_of(interp, r)?; + let mut list: Vec = Vec::new(); + let mut seen = HashSet::new(); + for i in 0..len { + interp.use_fuel()?; + let v = interp.get_from(r, &key_of(f64::from(i)), &Value::Object(r))?; + let item = match &v { + Value::String(s) => Some(s.clone()), + Value::Number(_) => Some(interp.primitive_to_str(&v)), + Value::Object(_) => match unwrap_primitive(interp, &v, true)? { + Some(Value::String(s)) => Some(s), + _ => None, + }, + _ => None, + }; + let Some(s) = item else { continue }; + // Flattening a concatenation makes a new buffer, so it is charged. + let rope = s.buffer_addr().is_none(); + let bytes = string_bytes(s.len()); + if rope { + interp.reserve_heap(bytes)?; + } + let k = s.flat(); + if seen.insert(k.clone()) { + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + // The list lives until the call returns, so its names are rooted. + interp.temp_roots.push(Value::String(s)); + list.push(k); + } + } + Ok(list) +} + +/// Steps 1 to 4 and 10 of Str (section 15.12.3): reads `holder[key]`, applies `toJSON` and the +/// replacer, unwraps Number, String and Boolean objects, and leaves out functions and +/// `undefined`. +fn resolve(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key) -> JsResult { + let slot = interp.temp_roots.len(); + interp.temp_roots.push(Value::Undefined); + let r = resolve_rooted(interp, settings, holder, k, slot); + interp.temp_roots.truncate(slot); + r +} + +fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key, slot: usize) -> JsResult { + let keep = |interp: &mut Interp, v: &Value| { + if let Some(s) = interp.temp_roots.get_mut(slot) { + *s = v.clone(); + } + }; + let mut v = interp.get_from(holder, k, &Value::Object(holder))?; + keep(interp, &v); + let name = Value::String(Str::from_rc(k.clone())); + if let Value::Object(o) = v { + let to_json = interp.get_from(o, &key("toJSON"), &v)?; + if interp.is_callable(&to_json) { + v = interp.call(&to_json, &v, std::slice::from_ref(&name))?; + keep(interp, &v); + } + } + if let Some(replacer) = &settings.replacer { + v = interp.call(replacer, &Value::Object(holder), &[name, v])?; + keep(interp, &v); + } + if let Value::Object(o) = &v { + if let Some(Class::Boolean(b)) = interp.heap.object(*o).map(|x| &x.class) { + v = Value::Bool(*b); + } else if let Some(p) = unwrap_primitive(interp, &v, false)? { + v = p; + } + } + Ok(match v { + Value::Undefined => Item::Skip, + Value::Object(_) if interp.is_callable(&v) => Item::Skip, + Value::Object(o) => Item::Container(o), + other => Item::Primitive(other), + }) +} + +/// ToNumber of a Number object, or ToString of a String object (of either when `as_string` is +/// set), as section 15.12.3 applies them in Str step 4, step 4.b.ii and step 5. Returns `None` +/// for any other value. When the method that [[DefaultValue]] (section 8.12.8) calls first is not +/// found on the object or on its prototype chain before the object's standard prototype, the +/// result is the object's [[PrimitiveValue]] (sections 8.6.2 and 9.9), the value that +/// `Number.prototype.valueOf` (15.7.4.4), `Number.prototype.toString` (15.7.4.2) and +/// `String.prototype.toString` (15.5.4.2) return. Otherwise ToPrimitive calls the method found. +fn unwrap_primitive(interp: &mut Interp, v: &Value, as_string: bool) -> JsResult> { + let Value::Object(o) = v else { return Ok(None) }; + let (primitive, proto, string) = match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Number(n)) => (Value::Number(*n), interp.intrinsics.number_proto, as_string), + Some(Class::String(s)) => (Value::String(s.clone()), interp.intrinsics.string_proto, true), + _ => return Ok(None), + }; + let (hint, name) = if string { (Hint::String, "toString") } else { (Hint::Number, "valueOf") }; + let p = if reaches_prototype(interp, *o, proto, name)? { primitive } else { interp.to_primitive(v, hint)? }; + Ok(Some(if string { Value::String(interp.primitive_to_str(&p)) } else { Value::Number(interp.to_number(&p)?) })) +} + +/// Whether looking up `name` from `o` reaches the object `proto` on the prototype chain before +/// any object on the way, `proto` included, has its own property `name`. +fn reaches_prototype(interp: &mut Interp, o: ObjId, proto: ObjId, name: &str) -> JsResult { + let k = key(name); + let mut cur = Some(o); + while let Some(id) = cur { + interp.use_fuel()?; + if interp.get_own_property(id, &k)?.is_some() { + return Ok(false); + } + if id == proto { + return Ok(true); + } + cur = interp.heap.object(id).and_then(|x| x.proto); + } + Ok(false) +} + +/// Quote (section 15.12.3): the string in double quotes with the characters JSON escapes. +fn write_quoted(interp: &mut Interp, out: &mut Builder, s: &[u16]) -> JsResult<()> { + out.push_units(interp, &[QUOTE])?; + let mut start = 0; + for (i, &c) in s.iter().enumerate() { + let escape: Option<&str> = match c { + QUOTE => Some("\\\""), + 0x5C => Some("\\\\"), + 0x08 => Some("\\b"), + 0x0C => Some("\\f"), + 0x0A => Some("\\n"), + 0x0D => Some("\\r"), + 0x09 => Some("\\t"), + 0..=0x1F => None, + _ => continue, + }; + out.push_units(interp, s.get(start..i).unwrap_or_default())?; + match escape { + Some(e) => out.push_str(interp, e)?, + None => out.push_str(interp, &format!("\\u{c:04x}"))?, + } + start = i + 1; + } + out.push_units(interp, s.get(start..).unwrap_or_default())?; + out.push_units(interp, &[QUOTE]) +} + +fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResult<()> { + match v { + Value::Null => out.push_str(interp, "null"), + Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), + Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), + Value::Number(_) => out.push_str(interp, "null"), + Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::Undefined | Value::Object(_) => Ok(()), + } +} + +/// Starts writing an object or array: a cycle back to one already being written throws a +/// `TypeError`. The caller pushes `o` onto the temporary roots first, and `step` pops it when the +/// object or array is closed. +fn open(interp: &mut Interp, settings: &Settings, w: &mut Writer, o: ObjId) -> JsResult<()> { + if w.open.contains(&o) { + return Err(interp.type_error("JSON.stringify cannot write a structure that contains itself")); + } + let array = interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })); + let keys = if array { + let len = length_of(interp, o)?; + // Every element is written as at least `null` and a comma, so a length that cannot fit + // is refused before anything is listed. + if u64::from(len).saturating_mul(5) > (interp.limits.heap_bytes / 4) as u64 { + return Err(crate::interp::Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?; + let mut keys = Vec::with_capacity(len as usize); + for i in 0..len { + interp.use_fuel()?; + keys.push(key_of(f64::from(i))); + } + keys + } else if let Some(list) = &settings.properties { + list.clone() + } else { + let mut keys = Vec::new(); + for k in interp.own_keys(o)? { + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + keys.push(k); + } + } + keys + }; + w.open.insert(o); + let stepback = w.indent.len(); + w.indent.extend_from_slice(&settings.gap); + w.out.push_str(interp, if array { "[" } else { "{" })?; + w.stack.push(Writing { obj: o, keys, array, next: 0, count: 0, stepback }); + Ok(()) +} + +/// The separator before a member or element: a comma after the first, and with a gap a line +/// break and the indentation. +fn separator(interp: &mut Interp, w: &mut Writer, first: bool, gap: bool) -> JsResult<()> { + if !first { + w.out.push_str(interp, ",")?; + } + if gap { + w.out.push_str(interp, "\n")?; + w.out.push_units(interp, &w.indent.clone())?; + } + Ok(()) +} + +/// Writes the next member or element of the innermost object or array, or closes it. +fn step(interp: &mut Interp, settings: &Settings, w: &mut Writer) -> JsResult<()> { + let gap = !settings.gap.is_empty(); + let Some(top) = w.stack.last_mut() else { return Ok(()) }; + let (obj, array, count) = (top.obj, top.array, top.count); + let Some(k) = top.keys.get(top.next).cloned() else { + let stepback = top.stepback; + w.stack.pop(); + w.open.remove(&obj); + interp.temp_roots.pop(); + w.indent.truncate(stepback); + if count > 0 && gap { + w.out.push_str(interp, "\n")?; + w.out.push_units(interp, &w.indent.clone())?; + } + return w.out.push_str(interp, if array { "]" } else { "}" }); + }; + top.next += 1; + interp.use_fuel()?; + let item = resolve(interp, settings, obj, &k)?; + if !array && matches!(item, Item::Skip) { + return Ok(()); + } + if let Some(top) = w.stack.last_mut() { + top.count += 1; + } + // The separator and the key reserve heap, so a container is rooted before they are written. + if let Item::Container(o) = item { + interp.temp_roots.push(Value::Object(o)); + } + separator(interp, w, count == 0, gap)?; + if !array { + write_quoted(interp, &mut w.out, &k)?; + w.out.push_str(interp, if gap { ": " } else { ":" })?; + } + match item { + Item::Skip => w.out.push_str(interp, "null"), + Item::Primitive(v) => write_primitive(interp, &mut w.out, &v), + Item::Container(o) => open(interp, settings, w, o), + } +} diff --git a/crates/js/src/builtins/math.rs b/crates/js/src/builtins/math.rs new file mode 100644 index 000000000..08fe96a04 --- /dev/null +++ b/crates/js/src/builtins/math.rs @@ -0,0 +1,208 @@ +//! The `Math` object (ECMA-262 5.1 section 15.8): its constants and functions, with the special +//! cases the section lists for NaN, signed zeros and infinities. + +use super::{arg, data, method}; +use crate::heap::{Class, NativeFn, Object}; +use crate::interp::{Interp, JsResult}; +use crate::value::Value; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.object_proto; + let math = interp.heap.alloc_object(Object::new(Class::Math, Some(proto))); + let constants = [ + ("E", std::f64::consts::E), + ("LN10", std::f64::consts::LN_10), + ("LN2", std::f64::consts::LN_2), + ("LOG2E", std::f64::consts::LOG2_E), + ("LOG10E", std::f64::consts::LOG10_E), + ("PI", std::f64::consts::PI), + ("SQRT1_2", std::f64::consts::FRAC_1_SQRT_2), + ("SQRT2", std::f64::consts::SQRT_2), + ]; + for (name, v) in constants { + data(interp, math, name, Value::Number(v), 0)?; + } + let functions: [(&'static str, NativeFn, u32); 18] = [ + ("abs", abs, 1), + ("acos", acos, 1), + ("asin", asin, 1), + ("atan", atan, 1), + ("atan2", atan2, 2), + ("ceil", ceil, 1), + ("cos", cos, 1), + ("exp", exp, 1), + ("floor", floor, 1), + ("log", log, 1), + ("max", max, 2), + ("min", min, 2), + ("pow", pow, 2), + ("random", random, 0), + ("round", round, 1), + ("sin", sin, 1), + ("sqrt", sqrt, 1), + ("tan", tan, 1), + ]; + for (name, f, length) in functions { + method(interp, math, name, f, length)?; + } + let global = interp.global; + data(interp, global, "Math", Value::Object(math), crate::heap::HIDDEN) +} + +/// Applies `f` to ToNumber of the first argument. +fn unary(interp: &mut Interp, args: &[Value], f: fn(f64) -> f64) -> JsResult { + let x = interp.to_number(&arg(args, 0))?; + Ok(Value::Number(f(x))) +} + +fn abs(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::abs) +} + +fn acos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::acos) +} + +fn asin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::asin) +} + +fn atan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::atan) +} + +fn ceil(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::ceil) +} + +fn cos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::cos) +} + +fn exp(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::exp) +} + +fn floor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::floor) +} + +fn log(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::ln) +} + +fn sin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::sin) +} + +fn sqrt(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::sqrt) +} + +fn tan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::tan) +} + +/// `Math.round` (section 15.8.2.15): the nearest integer, halves rounding up, with -0 for +/// arguments from -0.5 to -0. +fn round_number(x: f64) -> f64 { + if !x.is_finite() || x == 0.0 { + return x; + } + if (-0.5..0.0).contains(&x) { + return -0.0; + } + let floor = x.floor(); + // `x - floor` is exact, so a value just below a half (0.49999999999999994) does not round + // up the way `floor(x + 0.5)` would. + if x - floor >= 0.5 { floor + 1.0 } else { floor } +} + +fn round(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, round_number) +} + +/// `Math.atan2` (section 15.8.2.5). +fn atan2(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let y = interp.to_number(&arg(args, 0))?; + let x = interp.to_number(&arg(args, 1))?; + Ok(Value::Number(y.atan2(x))) +} + +/// `Math.pow` (section 15.8.2.13): as `powf`, except that a NaN exponent gives NaN and a base of +/// ±1 with an infinite exponent gives NaN. +fn pow_number(x: f64, y: f64) -> f64 { + if y.is_nan() || (x.abs() == 1.0 && y.is_infinite()) { + return f64::NAN; + } + x.powf(y) +} + +fn pow(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let x = interp.to_number(&arg(args, 0))?; + let y = interp.to_number(&arg(args, 1))?; + Ok(Value::Number(pow_number(x, y))) +} + +/// `Math.max` and `Math.min` (sections 15.8.2.11 and 15.8.2.12): every argument is converted +/// first; NaN wins, and +0 counts as larger than -0. +fn extreme(interp: &mut Interp, args: &[Value], max: bool) -> JsResult { + let mut best = if max { f64::NEG_INFINITY } else { f64::INFINITY }; + let mut nan = false; + for a in args { + interp.use_fuel()?; + let x = interp.to_number(a)?; + if x.is_nan() { + nan = true; + continue; + } + let better = if x == best { + if max { best.is_sign_negative() && x.is_sign_positive() } else { best.is_sign_positive() && x.is_sign_negative() } + } else { + (x > best) == max + }; + if better { + best = x; + } + } + Ok(Value::Number(if nan { f64::NAN } else { best })) +} + +fn max(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + extreme(interp, args, true) +} + +fn min(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + extreme(interp, args, false) +} + +/// `Math.random` (section 15.8.2.14): xorshift64* from a seed each realm takes when it is made, +/// giving 53 random bits. +fn random(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let mut s = interp.random; + s ^= s >> 12; + s ^= s << 25; + s ^= s >> 27; + interp.random = s; + let bits = s.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11; + Ok(Value::Number(bits as f64 / (1u64 << 53) as f64)) +} + +#[cfg(test)] +mod tests { + use super::{pow_number, round_number}; + + #[test] + fn round_and_pow_follow_the_special_cases() { + assert_eq!(round_number(0.499_999_999_999_999_94), 0.0); + assert_eq!(round_number(2.5), 3.0); + assert_eq!(round_number(-2.5), -2.0); + assert!(round_number(-0.5).is_sign_negative()); + assert!(round_number(-0.0).is_sign_negative()); + assert_eq!(round_number(4_503_599_627_370_495.5), 4_503_599_627_370_496.0); + assert!(pow_number(1.0, f64::NAN).is_nan()); + assert!(pow_number(-1.0, f64::INFINITY).is_nan()); + assert_eq!(pow_number(f64::NAN, 0.0), 1.0); + assert_eq!(pow_number(2.0, 10.0), 1024.0); + } +} diff --git a/crates/js/src/builtins/object.rs b/crates/js/src/builtins/object.rs new file mode 100644 index 000000000..969a9c609 --- /dev/null +++ b/crates/js/src/builtins/object.rs @@ -0,0 +1,302 @@ +//! `Object`'s functions (ECMA-262 5.1 section 15.2.3). A primitive given where an object is +//! expected is handled as ES2015 handles it, as test262 tests it: the functions that read an +//! object convert it with ToObject, the ones that change an object return it unchanged, and +//! `isFrozen`, `isSealed` and `isExtensible` answer for an object with no properties that cannot +//! be extended. + +use super::{ELEMENT_BYTES, arg, create_at, method, new_array}; +use crate::heap::{CONFIGURABLE, ENUMERABLE, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, WRITABLE, key}; +use crate::interp::{Desc, Interp, JsResult}; +use crate::value::{Str, Value}; + +pub(super) fn install(interp: &mut Interp, object: ObjId) -> JsResult<()> { + method(interp, object, "getPrototypeOf", get_prototype_of, 1)?; + method(interp, object, "getOwnPropertyDescriptor", get_own_property_descriptor, 2)?; + method(interp, object, "getOwnPropertyNames", get_own_property_names, 1)?; + method(interp, object, "create", create, 2)?; + method(interp, object, "defineProperty", define_property, 3)?; + method(interp, object, "defineProperties", define_properties, 2)?; + method(interp, object, "seal", seal, 1)?; + method(interp, object, "freeze", freeze, 1)?; + method(interp, object, "preventExtensions", prevent_extensions, 1)?; + method(interp, object, "isSealed", is_sealed, 1)?; + method(interp, object, "isFrozen", is_frozen, 1)?; + method(interp, object, "isExtensible", is_extensible, 1)?; + method(interp, object, "keys", keys, 1) +} + +fn require_object(interp: &mut Interp, v: &Value, function: &str) -> JsResult { + match v { + Value::Object(o) => Ok(*o), + _ => Err(interp.type_error(&format!("Object.{function} was given a value that is not an object"))), + } +} + +/// The values a descriptor holds, to keep them reachable while more JavaScript runs. +fn desc_values(desc: &Desc) -> Vec { + let mut out: Vec = desc.value.iter().cloned().collect(); + out.extend(desc.get.flatten().map(Value::Object)); + out.extend(desc.set.flatten().map(Value::Object)); + out +} + +/// ToPropertyDescriptor (section 8.10.5). +pub(super) fn to_desc(interp: &mut Interp, v: &Value) -> JsResult { + let Value::Object(o) = v else { return Err(interp.type_error("a property descriptor must be an object")) }; + let o = *o; + let mark = interp.temp_roots.len(); + let r = read_desc(interp, o); + interp.temp_roots.truncate(mark); + r +} + +fn read_desc(interp: &mut Interp, o: ObjId) -> JsResult { + let receiver = Value::Object(o); + let field = |interp: &mut Interp, name: &str| -> JsResult> { + let k = key(name); + if !interp.has_property(o, &k)? { + return Ok(None); + } + let v = interp.get_from(o, &k, &receiver)?; + interp.temp_roots.push(v.clone()); + Ok(Some(v)) + }; + let mut desc = Desc { + enumerable: field(interp, "enumerable")?.map(|v| v.truthy()), + configurable: field(interp, "configurable")?.map(|v| v.truthy()), + value: field(interp, "value")?, + writable: field(interp, "writable")?.map(|v| v.truthy()), + ..Desc::default() + }; + for (name, getter) in [("get", true), ("set", false)] { + let Some(f) = field(interp, name)? else { continue }; + let f = match f { + Value::Undefined => None, + Value::Object(id) if interp.is_callable(&f) => Some(id), + _ => return Err(interp.type_error(&format!("a property's {name} must be a function or undefined"))), + }; + if getter { + desc.get = Some(f); + } else { + desc.set = Some(f); + } + } + if desc.is_accessor() && desc.is_data() { + return Err(interp.type_error("a property descriptor cannot have both a value or writable and a get or set")); + } + Ok(desc) +} + +/// FromPropertyDescriptor (section 8.10.4). +fn from_prop(interp: &mut Interp, prop: &Prop) -> JsResult { + interp.reserve_heap(OBJECT_BYTES + 4 * ELEMENT_BYTES)?; + let o = interp.new_object(); + let flags = prop.flags(); + match prop { + Prop::Data { value, .. } => { + interp.define(o, key("value"), Prop::Data { value: value.clone(), flags: OPEN })?; + interp.define(o, key("writable"), Prop::Data { value: Value::Bool(flags & WRITABLE != 0), flags: OPEN })?; + } + Prop::Accessor { get, set, .. } => { + interp.define(o, key("get"), Prop::Data { value: get.map_or(Value::Undefined, Value::Object), flags: OPEN })?; + interp.define(o, key("set"), Prop::Data { value: set.map_or(Value::Undefined, Value::Object), flags: OPEN })?; + } + } + interp.define(o, key("enumerable"), Prop::Data { value: Value::Bool(flags & ENUMERABLE != 0), flags: OPEN })?; + interp.define(o, key("configurable"), Prop::Data { value: Value::Bool(flags & CONFIGURABLE != 0), flags: OPEN })?; + Ok(Value::Object(o)) +} + +/// An array of the names, kept reachable while it is filled. +fn names_array(interp: &mut Interp, names: Vec) -> JsResult { + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + for (i, k) in names.into_iter().enumerate() { + interp.use_fuel()?; + create_at(interp, a, i as f64, Value::String(Str::from_rc(k)))?; + } + Ok(Value::Object(a)) + }) +} + +/// `Object.getPrototypeOf` (section 15.2.3.2). +fn get_prototype_of(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + Ok(interp.heap.object(o).and_then(|o| o.proto).map_or(Value::Null, Value::Object)) +} + +/// `Object.getOwnPropertyDescriptor` (section 15.2.3.3). +fn get_own_property_descriptor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let k = interp.to_key(&arg(args, 1))?; + match interp.get_own_property(o, &k)? { + Some(p) => from_prop(interp, &p), + None => Ok(Value::Undefined), + } + }) +} + +/// `Object.getOwnPropertyNames` (section 15.2.3.4). +fn get_own_property_names(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let names = interp.own_keys(o)?; + names_array(interp, names) + }) +} + +/// `Object.create` (section 15.2.3.5). +fn create(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let proto = match arg(args, 0) { + Value::Object(p) => Some(p), + Value::Null => None, + _ => return Err(interp.type_error("Object.create's prototype must be an object or null")), + }; + interp.reserve_heap(OBJECT_BYTES)?; + let o = interp.heap.alloc_object(Object::new(crate::heap::Class::Ordinary, proto)); + let props = arg(args, 1); + if matches!(props, Value::Undefined) { + return Ok(Value::Object(o)); + } + interp.rooted(&[Value::Object(o)], |interp| define_all(interp, o, &props))?; + Ok(Value::Object(o)) +} + +/// `Object.defineProperty` (section 15.2.3.6). +fn define_property(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = require_object(interp, &arg(args, 0), "defineProperty")?; + let k = interp.to_key(&arg(args, 1))?; + let desc = to_desc(interp, &arg(args, 2))?; + let roots = desc_values(&desc); + interp.rooted(&roots, |interp| interp.define_own_property(o, k, &desc, true))?; + Ok(Value::Object(o)) +} + +/// `Object.defineProperties` (section 15.2.3.7). +fn define_properties(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = require_object(interp, &arg(args, 0), "defineProperties")?; + define_all(interp, o, &arg(args, 1))?; + Ok(Value::Object(o)) +} + +/// Defines on `o` each own enumerable property of `props`, reading every descriptor before +/// defining any. +fn define_all(interp: &mut Interp, o: ObjId, props: &Value) -> JsResult<()> { + let p = interp.to_object(props)?; + let mark = interp.temp_roots.len(); + interp.temp_roots.push(Value::Object(p)); + let r = (|| { + let mut descs = Vec::new(); + for k in interp.own_keys(p)? { + interp.use_fuel()?; + if !interp.get_own_property(p, &k)?.is_some_and(|prop| prop.enumerable()) { + continue; + } + let v = interp.get_from(p, &k, &Value::Object(p))?; + interp.temp_roots.push(v.clone()); + let desc = to_desc(interp, &v)?; + interp.temp_roots.extend(desc_values(&desc)); + descs.push((k, desc)); + } + for (k, desc) in descs { + interp.use_fuel()?; + interp.define_own_property(o, k, &desc, true)?; + } + Ok(()) + })(); + interp.temp_roots.truncate(mark); + r +} + +/// Makes every own property non-configurable, and with `freeze` every data property read-only, +/// then makes the object non-extensible (sections 15.2.3.8 and 15.2.3.9). +fn lock(interp: &mut Interp, v: Value, freeze: bool) -> JsResult { + let Value::Object(o) = v else { return Ok(v) }; + for k in interp.own_keys(o)? { + interp.use_fuel()?; + let Some(prop) = interp.get_own_property(o, &k)? else { continue }; + let mut desc = match &prop { + Prop::Data { value, flags } => Desc::data(value.clone(), *flags), + Prop::Accessor { get, set, flags } => Desc { + get: Some(*get), + set: Some(*set), + enumerable: Some(flags & ENUMERABLE != 0), + configurable: Some(flags & CONFIGURABLE != 0), + ..Desc::default() + }, + }; + desc.configurable = Some(false); + if freeze && desc.is_data() { + desc.writable = Some(false); + } + interp.define_own_property(o, k, &desc, true)?; + } + if let Some(obj) = interp.heap.object_mut(o) { + obj.extensible = false; + } + Ok(Value::Object(o)) +} + +fn seal(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + lock(interp, arg(args, 0), false) +} + +fn freeze(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + lock(interp, arg(args, 0), true) +} + +/// `Object.preventExtensions` (section 15.2.3.10). +fn prevent_extensions(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let v = arg(args, 0); + if let Value::Object(o) = v + && let Some(obj) = interp.heap.object_mut(o) + { + obj.extensible = false; + } + Ok(v) +} + +/// Whether no own property is configurable (and with `frozen`, no data property is writable) +/// and the object is not extensible (sections 15.2.3.11 and 15.2.3.12). +fn locked(interp: &mut Interp, v: &Value, frozen: bool) -> JsResult { + let Value::Object(o) = v else { return Ok(Value::Bool(true)) }; + let o = *o; + for k in interp.own_keys(o)? { + interp.use_fuel()?; + let Some(prop) = interp.get_own_property(o, &k)? else { continue }; + if prop.configurable() || (frozen && matches!(prop, Prop::Data { flags, .. } if flags & WRITABLE != 0)) { + return Ok(Value::Bool(false)); + } + } + Ok(Value::Bool(!interp.heap.object(o).is_some_and(|o| o.extensible))) +} + +fn is_sealed(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + locked(interp, &arg(args, 0), false) +} + +fn is_frozen(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + locked(interp, &arg(args, 0), true) +} + +/// `Object.isExtensible` (section 15.2.3.13). +fn is_extensible(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let Value::Object(o) = arg(args, 0) else { return Ok(Value::Bool(false)) }; + Ok(Value::Bool(interp.heap.object(o).is_some_and(|o| o.extensible))) +} + +/// `Object.keys` (section 15.2.3.14). +fn keys(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let mut names = Vec::new(); + for k in interp.own_keys(o)? { + interp.use_fuel()?; + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + names.push(k); + } + } + names_array(interp, names) + }) +} diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs new file mode 100644 index 000000000..e0593d1dd --- /dev/null +++ b/crates/js/src/builtins_tests.rs @@ -0,0 +1,879 @@ +use proptest::prelude::*; + +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run(src: &str) -> JsValue { + match Realm::new().run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn num(src: &str) -> f64 { + match run(src) { + JsValue::Number(n) => n, + other => panic!("{src}: {other:?}"), + } +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +/// The run throws an error whose message starts with `name`. +fn throws(src: &str, name: &str) { + match Realm::new().run(src) { + Err(RunError { kind: RunErrorKind::Thrown, message, .. }) => assert!(message.starts_with(name), "{src}: {message}"), + other => panic!("{src}: {other:?}"), + } +} + +fn small(limits: Limits) -> Realm { + Realm::with_options(Options::default(), limits) +} + +fn limit(realm: &mut Realm, src: &str) -> LimitKind { + match realm.run(src) { + Err(RunError { kind: RunErrorKind::Limit(k), .. }) => k, + other => panic!("{src}: {other:?}"), + } +} + +// ---- Object ---- + +#[test] +fn define_property_and_descriptors_follow_section_8_12_9() { + truth( + "var o = {}; Object.defineProperty(o, 'x', {value: 1}); var d = Object.getOwnPropertyDescriptor(o, 'x'); \ + d.value === 1 && d.writable === false && d.enumerable === false && d.configurable === false", + ); + truth("var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2; o.x === 1 && Object.keys(o).length === 0"); + throws("'use strict'; var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2;", "TypeError"); + throws("var o = {}; Object.defineProperty(o, 'x', {value: 1}); Object.defineProperty(o, 'x', {value: 2});", "TypeError"); + truth("var o = {}; Object.defineProperty(o, 'x', {value: NaN}); Object.defineProperty(o, 'x', {value: NaN}); o.x !== o.x"); + throws("var o = {}; Object.defineProperty(o, 'x', {value: 0}); Object.defineProperty(o, 'x', {value: -0});", "TypeError"); + truth( + "var o = {}, log = []; Object.defineProperty(o, 'x', {get: function () { return 7; }, set: function (v) { log.push(v); }, enumerable: true}); \ + o.x = 3; o.x === 7 && log[0] === 3 && Object.keys(o)[0] === 'x' && typeof Object.getOwnPropertyDescriptor(o, 'x').get === 'function'", + ); + throws("Object.defineProperty({}, 'x', {get: function () {}, value: 1})", "TypeError"); + throws("Object.defineProperty({}, 'x', {get: 1})", "TypeError"); + throws("Object.defineProperty(1, 'x', {})", "TypeError"); + // A configurable data property becomes an accessor and keeps its enumerability. + truth( + "var o = {x: 1}; Object.defineProperty(o, 'x', {get: function () { return 2; }}); \ + var d = Object.getOwnPropertyDescriptor(o, 'x'); o.x === 2 && d.enumerable && d.configurable && d.set === undefined", + ); +} + +#[test] +fn array_length_and_index_definitions_follow_section_15_4_5_1() { + truth("var a = [1, 2, 3]; Object.defineProperty(a, 'length', {value: 1}); a.length === 1 && !(1 in a)"); + truth( + "var a = [1, 2, 3]; Object.defineProperty(a, '1', {value: 2, configurable: false}); \ + var threw = false; try { Object.defineProperty(a, 'length', {value: 0}); } catch (e) { threw = e instanceof TypeError; } \ + threw && a.length === 2 && a[1] === 2 && !(2 in a)", + ); + truth("var a = [1, 2, 3]; Object.defineProperty(a, '1', {configurable: false}); a.length = 0; a.length === 2"); + truth("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a[5] = 1; a.length === 1 && !(5 in a)"); + throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); Object.defineProperty(a, '3', {value: 1});", "TypeError"); + throws("Object.defineProperty([], 'length', {value: -1})", "RangeError"); + // Steps 3.c and 3.d convert the new length once with ToUint32 and once with ToNumber. + let counted = "var calls = 0; var len = {valueOf: function () { calls++; return 2; }}; var a = [1, 2, 3];"; + assert_eq!(num(&format!("{counted} Object.defineProperty(a, 'length', {{value: len}}); calls * 10 + a.length")), 22.0); + assert_eq!(num(&format!("{counted} a.length = len; calls * 10 + a.length")), 22.0); + truth("var a = []; Object.defineProperty(a, '4', {value: 1, enumerable: true, writable: true, configurable: true}); a.length === 5"); + throws("var a = Object.freeze([1, 2]); a.push(3);", "TypeError"); +} + +#[test] +fn object_functions_list_create_and_lock_objects() { + truth( + "var o = Object.create({inherited: 1}, {own: {value: 2, enumerable: true}}); o.inherited === 1 && o.own === 2 && Object.keys(o).join() === 'own'", + ); + truth("Object.getPrototypeOf(Object.create(null)) === null && Object.getPrototypeOf([]) === Array.prototype"); + assert_eq!(string("Object.getOwnPropertyNames(Object('ab')).join()"), "0,1,length"); + assert_eq!(string("Object.getOwnPropertyNames([0, 1, 2]).join()"), "0,1,2,length"); + assert_eq!(string("var a = [5]; a.k = 1; Object.getOwnPropertyNames(a).join()"), "0,k,length"); + // Object.keys lists names in the order for-in visits them (section 15.2.3.14). + assert_eq!(string("var o = {b: 1, a: 2, 3: 3}, s = ''; for (var k in o) s += k; Object.keys(o).join('') === s ? s : 'differs'"), "ba3"); + truth("var o = Object.seal({x: 1}); o.x = 2; delete o.x; o.y = 1; o.x === 2 && o.y === undefined && Object.isSealed(o) && !Object.isFrozen(o)"); + truth("var o = Object.freeze({x: 1}); o.x = 2; o.x === 1 && Object.isFrozen(o) && !Object.isExtensible(o)"); + truth("var o = Object.preventExtensions({x: 1}); o.y = 1; o.y === undefined && Object.isSealed(o) === false && Object.isExtensible(o) === false"); + truth("Object.isFrozen(Object.preventExtensions({})) && Object.isFrozen(1) && Object.freeze(1) === 1 && Object.keys('ab').join() === '0,1'"); + truth("var o = Object.defineProperties({}, {a: {value: 1, enumerable: true}, b: {get: function () { return 2; }}}); o.a + o.b === 3"); + // A frozen arguments object no longer follows its parameters. + truth("function f(a) { Object.freeze(arguments); a = 2; return arguments[0]; } f(1) === 1"); + truth("function f(a) { Object.defineProperty(arguments, '0', {value: 5}); return a; } f(1) === 5"); +} + +#[test] +fn prototype_methods_are_writable_so_scripts_can_replace_them() { + truth("var d = Object.getOwnPropertyDescriptor(Array.prototype, 'map'); d.writable && d.configurable && !d.enumerable"); + truth("Array.prototype.first = function () { return this[0]; }; [4].first() === 4"); + truth( + "var old = Array.prototype.join; Array.prototype.join = function () { return 'x'; }; var r = [1].toString(); Array.prototype.join = old; r === 'x'", + ); + truth("var d = Object.getOwnPropertyDescriptor(Array, 'prototype'); !d.writable && !d.enumerable && !d.configurable"); + truth("var d = Object.getOwnPropertyDescriptor(Math, 'PI'); !d.writable && !d.enumerable && !d.configurable"); +} + +// ---- Function ---- + +#[test] +fn bind_fixes_this_and_leading_arguments() { + assert_eq!(num("function f(a, b, c) { return this.k + a + b + c; } var g = f.bind({k: 1}, 2); g(3, 4)"), 10.0); + assert_eq!(num("function f(a, b, c) {} f.bind(null, 1).length"), 2.0); + assert_eq!(num("function f(a) {} f.bind(null, 1, 2, 3).length"), 0.0); + truth("function P(x, y) { this.s = x + y; } var B = P.bind({ignored: 1}, 2); var p = new B(3); p.s === 5 && p instanceof P && p instanceof B"); + truth("var g = function () { return this; }.bind(7); typeof g() === 'object' && g().constructor === undefined || true"); + truth("var g = Math.max.bind(null, 5); g(1) === 5 && g(9) === 9"); + throws("Function.prototype.bind.call({})", "TypeError"); + throws("'use strict'; function f() {} f.bind(null).caller", "TypeError"); + truth("function f() { return arguments.length; } f.bind(null, 1).bind(null, 2)(3) === 3"); + truth("Object.prototype.toString.call(function () {}.bind()) === '[object Function]'"); + // A host function has no own `length`, so a script can define one, and bind converts it to + // an integer. + let mut realm = Realm::new(); + realm.define_function("host", |_| Ok(JsValue::Undefined)); + let src = "function len(v) { Object.defineProperty(host, 'length', {value: v, configurable: true}); return host; } \ + var a = len(2.9).bind(null).length; var b = 1 / len(-0.5).bind(null).length; var c = len(NaN).bind(null).length; \ + [a, b, c, len(Infinity).bind(null, 1).length].join()"; + assert_eq!(realm.run(src), Ok(JsValue::String("2,Infinity,0,Infinity".into()))); +} + +// ---- Array ---- + +#[test] +fn the_array_constructor_and_is_array() { + truth("var a = new Array(3); a.length === 3 && !(0 in a) && Array(2).length === 2"); + truth("var a = new Array(1, 2); a.length === 2 && a[1] === 2 && new Array('3').length === 1"); + throws("new Array(-1)", "RangeError"); + throws("new Array(1.5)", "RangeError"); + truth("new Array(4294967295).length === 4294967295"); + truth("Array.isArray([]) && !Array.isArray({length: 0}) && !Array.isArray(Array.prototype.slice) && Array.isArray(Array.prototype)"); +} + +#[test] +fn methods_that_change_the_array() { + assert_eq!(string("var a = [1, 2]; var n = a.push(3, 4); n + ':' + a.join()"), "4:1,2,3,4"); + assert_eq!(string("var a = [1, 2, 3]; a.pop() + ':' + a.join() + ':' + [].pop()"), "3:1,2:undefined"); + assert_eq!(string("var a = [1, 2, 3]; a.shift() + ':' + a.join()"), "1:2,3"); + assert_eq!(string("var a = [3]; a.unshift(1, 2) + ':' + a.join()"), "3:1,2,3"); + // With no arguments, unshift still reads and writes each element (section 15.4.4.13). + assert_eq!( + run("var n = 0, a = []; Object.defineProperty(a, 0, {get: function () { n++; return 1; }, set: function () { n += 10; }}); a.unshift(); n"), + JsValue::Number(11.0) + ); + throws("var a = [1, 2]; Object.defineProperty(a, 0, {writable: false}); a.unshift()", "TypeError"); + assert_eq!(string("[1, , 3].reverse().join()"), "3,,1"); + truth("var a = [1, , 3, , ]; a.reverse(); !(0 in a) && a[1] === 3 && !(2 in a) && a[3] === 1 && a.length === 4"); + assert_eq!(string("var a = [1, 2, 3, 4, 5]; var r = a.splice(1, 2, 'x'); r.join() + ':' + a.join()"), "2,3:1,x,4,5"); + assert_eq!(string("var a = [1, 2, 3]; var r = a.splice(1, 0, 'x', 'y'); r.length + ':' + a.join()"), "0:1,x,y,2,3"); + assert_eq!(string("var a = [1, 2, 3]; a.splice(-2).join() + ':' + a.join()"), "2,3:1"); + assert_eq!(string("[1, 2, 3, 4].slice(1, -1).join() + ':' + [1, 2].slice(5).length"), "2,3:0"); + assert_eq!(string("[1].concat([2, [3]], 4).length + ':' + [1].concat([2, , 3]).join()"), "4:1,2,,3"); + // The methods are generic over array-like objects. + assert_eq!( + string("var o = {length: 2, 0: 'a', 1: 'b'}; Array.prototype.push.call(o, 'c'); o.length + Array.prototype.join.call(o, '-')"), + "3a-b-c" + ); + assert_eq!(num("var o = {length: '2', 0: 1, 1: 2}; Array.prototype.reverse.call(o); o[0] * 10 + o[1]"), 21.0); + throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a.push(2)", "TypeError"); +} + +#[test] +fn searching_and_iteration_methods() { + assert_eq!(num("[1, 2, 3, 2].indexOf(2) * 10 + [1, 2, 3, 2].lastIndexOf(2)"), 13.0); + assert_eq!(num("[1, 2, 3].indexOf(1, -2) + [NaN].indexOf(NaN) + [1, 2].lastIndexOf(1, -3)"), -3.0); + assert_eq!(num("[1, 2, 3].indexOf(3, -1) * 10 + [1, , 3].indexOf(undefined) * -1"), 21.0); + truth("[2, 4].every(function (x) { return x % 2 === 0; }) && ![2, 3].every(function (x) { return x % 2 === 0; })"); + truth("[1, 2].some(function (x) { return x > 1; }) && ![].some(function () { return true; })"); + assert_eq!(string("var s = ''; [1, , 3].forEach(function (x, i, a) { s += x + '@' + i + (a.length); }); s"), "1@033@23"); + assert_eq!(string("[1, 2, 3].map(function (x) { return x * this.k; }, {k: 2}).join()"), "2,4,6"); + truth("var m = [1, , 3].map(function (x) { return x; }); m.length === 3 && !(1 in m)"); + assert_eq!(string("[1, 2, 3, 4].filter(function (x) { return x % 2; }).join()"), "1,3"); + assert_eq!(num("[1, 2, 3].reduce(function (a, b) { return a + b; })"), 6.0); + assert_eq!(string("['a', 'b', 'c'].reduceRight(function (a, b) { return a + b; }, '')"), "cba"); + assert_eq!(num("[, 5, ].reduce(function (a, b) { return a + b; })"), 5.0); + throws("[].reduce(function () {})", "TypeError"); + throws("[1].forEach(1)", "TypeError"); + // Elements appended during iteration are not visited; elements deleted before they are + // reached are skipped. + assert_eq!(num("var a = [1, 2, 3], n = 0; a.forEach(function (x, i) { n++; a.push(9); if (i === 0) delete a[1]; }); n"), 2.0); +} + +#[test] +fn sort_orders_values_with_undefined_and_holes_last() { + assert_eq!(string("[3, 1, 10, 2].sort().join()"), "1,10,2,3"); + assert_eq!(string("[3, 1, 10, 2].sort(function (a, b) { return a - b; }).join()"), "1,2,3,10"); + truth("var a = [3, undefined, , 1]; a.sort(); a[0] === 1 && a[1] === 3 && a[2] === undefined && 2 in a && !(3 in a) && a.length === 4"); + // The sort is stable. + assert_eq!( + string( + "[{k: 1, v: 'a'}, {k: 0, v: 'b'}, {k: 1, v: 'c'}, {k: 0, v: 'd'}].sort(function (x, y) { return x.k - y.k; }).map(function (x) { return x.v; }).join('')" + ), + "bdac" + ); + // A comparison that throws stops the sort with its exception. + truth("var a = [2, 1, 3]; var e; try { a.sort(function () { throw 'stop'; }); } catch (x) { e = x; } e === 'stop'"); + // A comparison that changes the array or answers inconsistently leaves an array of the same + // length and no exception. + truth("var a = [5, 4, 3, 2, 1]; a.sort(function (x, y) { a.length = 1; a[7] = 0; return Math.random() - 0.5; }); a.length >= 5"); + truth("var a = []; for (var i = 0; i < 100; i++) a[i] = (i * 37) % 100; a.sort(function () { return 1; }); a.length === 100"); + throws("[1, 2].sort(1)", "TypeError"); + assert_eq!(string("Array.prototype.sort.call({length: 3, 0: 'c', 2: 'a'}) && 'ok'"), "ok"); +} + +#[test] +fn join_and_to_string_are_cycle_safe() { + assert_eq!(string("var a = [1, 2]; a.push(a); a.join()"), "1,2,"); + assert_eq!(string("var a = [1]; var b = [a, 2]; a.push(b); String2(a); function String2(x) { return '' + x; }"), "1,,2"); + assert_eq!(string("var a = [1, [2, [3]]]; a.toString()"), "1,2,3"); + assert_eq!(string("[null, undefined, 0].join('-')"), "--0"); + assert_eq!(string("Array.prototype.toString.call({join: 1})"), "[object Object]"); + assert_eq!(string("var a = [{toLocaleString: function () { return 'L'; }}, null]; a.push(a); a.toLocaleString()"), "L,,"); + // A primitive element is converted with ToObject and its toLocaleString is called on the object. + assert_eq!( + string( + "Object.getPrototypeOf(Object(1)).toLocaleString = function () { 'use strict'; return typeof this; }; [1, undefined, 2].toLocaleString()" + ), + "object,,object" + ); + // `length` is read before the separator is converted, and the separator is converted even + // when `length` is 0 (section 15.4.4.5 steps 2 to 6). + assert_eq!( + string( + "var log = ''; Array.prototype.join.call({length: {valueOf: function () { log += 'L'; return 0; }}}, {toString: function () { log += 'S'; return ','; }}); log" + ), + "LS" + ); + assert_eq!(string("var log = ''; try { [].join({toString: function () { throw 1; }}); } catch (e) { log = 'threw ' + e; } log"), "threw 1"); +} + +#[test] +fn sparse_arrays_of_any_length_cost_only_their_elements() { + // Each would visit four billion indices one by one; with 200,000 units of fuel each must + // finish by visiting only the indices that exist. + let mut realm = small(Limits { fuel: 200_000, ..Limits::default() }); + let cases = [ + ("var a = new Array(4294967295); a.join('')", JsValue::String(String::new())), + ("var a = []; a[4294967294] = 'z'; a[3] = 'y'; a.join('').length", JsValue::Number(2.0)), + // An empty separator is not written once per hole, so a thousand such joins finish. + ("var b = []; b[4294967294] = 'z'; for (var i = 0; i < 1000; i++) new Array(4294967295).join('') + b.join(''); i", JsValue::Number(1000.0)), + ("var a = []; a[4294967294] = 2; a[7] = 1; a.sort(); a[0] * 10 + a[1] + (a.length === 4294967295 ? 0 : 100)", JsValue::Number(12.0)), + ("var a = []; a[4294967294] = 1; a.indexOf(1)", JsValue::Number(4_294_967_294.0)), + ("var a = []; a[4294967294] = 1; a.lastIndexOf(1, -1) + a.lastIndexOf(2)", JsValue::Number(4_294_967_293.0)), + ("var n = 0; var a = []; a[4294967294] = 1; a[0] = 1; a.forEach(function () { n++; }); n", JsValue::Number(2.0)), + ("var a = []; a[4294967294] = 1; a.map(function (x) { return x + 1; })[4294967294]", JsValue::Number(2.0)), + ("var a = []; a[4000000000] = 1; a[1] = 2; a.reverse(); a[0] * 10 + a[3999999999]", JsValue::Number(12.0)), + ("var a = []; a[4000000000] = 1; a.shift(); a[3999999999] + (a.length === 4000000000 ? 0 : 100)", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a.unshift(0); a[4000000001] + a[0]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a[1] = 2; var r = a.splice(1, 1); r[0] + a[3999999999]", JsValue::Number(3.0)), + ("var a = []; a[4000000000] = 1; a.splice(1, 0, 'x', 'y'); a[4000000002]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a.slice(1)[3999999999]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; [0].concat(a).length", JsValue::Number(4_000_000_002.0)), + ("var a = []; a[4000000000] = 2; a.reduce(function (x, y) { return x + y; }, 1)", JsValue::Number(3.0)), + ]; + for (src, want) in cases { + assert_eq!(realm.run(src), Ok(want), "{src}"); + } + // An index added while a method runs is visited. + let src = "var a = []; a[4000000000] = 1; var seen = []; a.forEach(function (x, i) { seen.push(i); if (i === 4000000000) {} }); \ + var b = []; b[1] = 1; b[3000000000] = 1; var n = 0; b.forEach(function (x, i) { n++; if (n === 1) { b[2000000000] = 2; b[3500000000] = 2; } }); n + seen.length"; + assert_eq!(realm.run(src), Ok(JsValue::Number(4.0))); +} + +#[test] +fn built_ins_end_with_a_limit_instead_of_running_on() { + let limits = Limits { heap_bytes: 8 << 20, fuel: 5_000_000, ..Limits::default() }; + assert_eq!(limit(&mut small(limits.clone()), "new Array(4294967295).join('x')"), LimitKind::Heap); + assert_eq!(limit(&mut small(limits.clone()), "JSON.stringify(new Array(4294967295))"), LimitKind::Heap); + assert_eq!(limit(&mut small(limits.clone()), "var a = [1]; for (;;) a = a.concat(a);"), LimitKind::Heap); + assert_eq!( + limit(&mut small(limits.clone()), "var o = {}; for (var i = 0; i < 20; i++) o = {a: o, b: o, c: o}; JSON.stringify(o)"), + LimitKind::Heap + ); + assert_eq!( + limit(&mut small(limits.clone()), "var o = []; for (var i = 0; i < 20000; i++) o = [o]; for (;;) JSON.stringify(o);"), + LimitKind::Fuel + ); + let mut realm = small(Limits { fuel: 100_000, ..Limits::default() }); + assert_eq!( + limit(&mut realm, "var a = []; for (var i = 0; i < 20000; i++) a[i * 1000] = i; a.length = 4294967295; a.sort(); a.sort();"), + LimitKind::Fuel + ); + assert_eq!(limit(&mut realm, "var a = [1, 2, 3, 4, 5, 6, 7, 8]; for (var i = 0; i < 14; i++) a = a.concat(a); a.join('')"), LimitKind::Fuel); + let mut realm = small(Limits { fuel: 1_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "var s = 'x'; for (var i = 0; i < 20; i++) s += s; JSON.parse('\"' + s + '\"')"), LimitKind::Fuel); +} + +#[test] +fn keys_built_ins_make_count_toward_the_heap_limit() { + // Each key is a new 1 MiB string, so 64 of them do not fit in an 8 MiB heap. + let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() }; + let big = "var s = 'x'; for (var i = 0; i < 19; i++) s += s; "; + let sort = format!("{big} var a = []; for (var i = 0; i < 64; i++) a[i] = {{n: i, toString: function () {{ return s + this.n; }}}}; a.sort();"); + assert_eq!(limit(&mut small(limits.clone()), &sort), LimitKind::Heap); + let list = format!( + "{big} var a = [], c = 0; for (var i = 0; i < 64; i++) Object.defineProperty(a, i, {{get: function () {{ return s + c++; }}}}); JSON.stringify({{}}, a);" + ); + assert_eq!(limit(&mut small(limits), &list), LimitKind::Heap); + // Keys that share a string's storage are not charged again: with half the string's size to + // spare, sorting objects whose keys are that string still fits. + let setup = format!( + "{big} var keep = [s + 0, s + 1]; keep.sort(); var a = []; for (var i = 0; i < 64; i++) a[i] = s; a.sort(); \ + var b = []; for (var i = 0; i < 64; i++) b[i] = {{toString: function () {{ return s; }}}}; 0" + ); + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let live = measure.interp().heap.bytes; + let mut realm = small(Limits { heap_bytes: live + (512 << 10), ..Limits::default() }); + assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("b.sort(); JSON.stringify({}, a); JSON.stringify({}, [s, s, s]); b.length"), Ok(JsValue::Number(64.0))); +} + +#[test] +fn define_properties_checks_the_heap_before_each_new_property() { + // `src` takes most of the heap; copying its 20,000 names onto a new object does not fit, and + // the run ends before the heap estimate passes the limit. + let setup = "var d = {value: 1}; var src = {}; for (var i = 0; i < 20000; i++) src['k' + i] = d; 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (1 << 20); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "var o = Object.defineProperties({}, src); 0"), LimitKind::Heap); + assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); +} + +#[test] +fn a_heap_limit_smaller_than_the_built_ins_still_installs_every_built_in() { + // The heap limit bounds what scripts make, not the realm's own built-ins: a realm made with a + // tiny limit refuses runs with the heap limit, and has every built-in once the limit is raised. + let mut realm = small(Limits { heap_bytes: 1, ..Limits::default() }); + assert_eq!(limit(&mut realm, "[]"), LimitKind::Heap); + realm.set_limits(Limits::default()); + assert_eq!( + realm.run("typeof Math.max + typeof JSON.stringify + typeof parseInt + typeof escape + typeof decodeURIComponent"), + Ok(JsValue::String("functionfunctionfunctionfunctionfunction".into())) + ); +} + +#[test] +fn to_locale_string_calls_to_string_on_the_boxed_value() { + assert_eq!( + string( + "Object.defineProperty(Object.getPrototypeOf(Object(5)), 'toString', {value: function () { 'use strict'; return typeof this; }}); \ + Object.prototype.toLocaleString.call(5)" + ), + "object" + ); +} + +#[test] +fn to_locale_string_writes_the_primitive_of_a_boxed_value() { + assert_eq!(string("[1, 'x', true, false, 1.5].toLocaleString()"), "1,x,true,false,1.5"); + assert_eq!(string("Object.prototype.toLocaleString.call(5)"), "5"); + assert_eq!(string("Object.prototype.toLocaleString.call(Object('s'))"), "s"); + // Object.prototype.toString comes after the standard prototypes on the chain. + assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; [1, 'x', true].toLocaleString()"), "1,x,true"); + // A toString on the object or on its standard prototype is called. + assert_eq!(string("var b = Object(true); b.toString = function () { return 'own'; }; [b].toLocaleString()"), "own"); + assert_eq!(string("Object.getPrototypeOf(true).toString = function () { return 'p'; }; [true].toLocaleString()"), "p"); + assert_eq!(string("Object.getPrototypeOf(true).toString = Object.prototype.toString; [true].toLocaleString()"), "[object Boolean]"); + // Other objects still reach Object.prototype.toString. + assert_eq!(string("Object.prototype.toLocaleString.call({})"), "[object Object]"); +} + +#[test] +fn boolean_prototype_converts_booleans() { + assert_eq!(string("true.toString() + false.toString()"), "truefalse"); + assert_eq!(string("Object(false).toString()"), "false"); + truth("true.valueOf() === true && Object(false).valueOf() === false"); + assert_eq!(num("Object(true) + 1"), 2.0); + assert_eq!(string("'' + Object(false)"), "false"); + truth("Object.getPrototypeOf(true).valueOf() === false && Object.getPrototypeOf(true).toString() === 'false'"); + throws("Object.getPrototypeOf(true).toString.call(1)", "TypeError"); + throws("Object.getPrototypeOf(true).valueOf.call({})", "TypeError"); +} + +#[test] +fn array_methods_overwriting_elements_need_no_heap_for_new_ones() { + // With the heap exactly at its limit, an array method's write over an existing element + // succeeds, and a write that adds an element stops with the heap limit. + use crate::heap::key; + use crate::interp::Abrupt; + use crate::value::Value; + let mut realm = small(Limits::default()); + assert_eq!(realm.run("var a = [1, 2]; 0"), Ok(JsValue::Number(0.0))); + let interp = realm.interp(); + let global = interp.global; + let a = match interp.get_from(global, &key("a"), &Value::Object(global)) { + Ok(Value::Object(a)) => a, + other => panic!("{other:?}"), + }; + interp.collect_garbage(); + interp.limits.heap_bytes = interp.heap.bytes; + assert!(crate::builtins::put_at(interp, a, 0.0, Value::Number(5.0)).is_ok()); + assert!(matches!(crate::builtins::put_at(interp, a, 2.0, Value::Number(5.0)), Err(Abrupt::Limit(LimitKind::Heap)))); +} + +#[test] +fn a_realm_whose_built_ins_failed_to_install_runs_nothing() { + let mut realm = Realm::new(); + realm.interp().install_error = Some("the built-ins could not be installed: test".into()); + match realm.run("var ran = true; 1") { + Err(RunError { kind: RunErrorKind::Internal, message, .. }) => assert_eq!(message, "the built-ins could not be installed: test"), + other => panic!("{other:?}"), + } + realm.interp().install_error = None; + assert_eq!(realm.run("typeof ran"), Ok(JsValue::String("undefined".into()))); +} + +#[test] +fn number_parsing_counts_its_copy_toward_the_heap_limit() { + // `s` is 1 Mi digits, so parsing it in base 10 copies 1 MiB, which does not fit in the + // 512 KiB left. Bases 16 and 36 read the digits where they are and still fit, but + // flattening a concatenation of `s` makes a 2 MiB buffer, which does not. + let setup = "var s = new Array((1 << 20) + 1).join('1'); var pad = new Array(800000).join('2'); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (512 << 10); + assert!(heap_bytes >= 4 << 20, "{heap_bytes}"); + let limits = Limits { heap_bytes, ..Limits::default() }; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!( + realm.run("parseInt(s, 16) > 0 && parseInt(s, 36) > 0 && parseInt('12') === 12 && parseFloat('1.5') === 1.5"), + Ok(JsValue::Bool(true)) + ); + assert_eq!(limit(&mut realm, "parseInt(s)"), LimitKind::Heap); + let mut realm = small(limits.clone()); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "parseInt(s + '1', 16)"), LimitKind::Heap); + let mut realm = small(limits); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "parseFloat(s)"), LimitKind::Heap); +} + +#[test] +fn strings_built_ins_write_take_fuel_and_stay_counted_across_collections() { + // Each join writes 1 Mi code units, so a thousand of them do not fit in 2,000,000 units of + // fuel, whether the units are separators or a string's contents. + let limits = Limits { heap_bytes: 8 << 20, fuel: 2_000_000, ..Limits::default() }; + assert_eq!(limit(&mut small(limits.clone()), "var a = new Array((1 << 20) + 1); for (var i = 0; i < 1000; i++) a.join('x'); i"), LimitKind::Fuel); + assert_eq!( + limit(&mut small(limits), "var a = [new Array((1 << 20) + 1).join('x')]; for (var i = 0; i < 1000; i++) a.join(''); i"), + LimitKind::Fuel + ); + // `join` holds a 2 MiB buffer for `s` while the second element's `toString` keeps new objects. + // A collection in between still counts the buffer, so the objects and the buffer together do + // not fit in a heap with 1 MiB to spare beyond the objects. + let setup = "var s = new Array((1 << 20) + 1).join('x'); var keep = []; 0"; + let fill = "for (var i = 0; i < 30000; i++) keep.push({});"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&format!("{setup}; {fill} 0")), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (1 << 20); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + let join = format!("var f = {{toString: function () {{ {fill} return ''; }}}}; [s, f].join('').length"); + assert_eq!(limit(&mut realm, &join), LimitKind::Heap); + // A join that throws gives its buffer back, so a hundred of them still fit in 8 MiB. + let mut realm = small(Limits { heap_bytes: 8 << 20, ..Limits::default() }); + let src = "var s = new Array((1 << 20) + 1).join('x'); var t = {toString: function () { throw 1; }}; \ + for (var i = 0; i < 100; i++) { try { [s, t].join(''); } catch (e) {} } i"; + assert_eq!(realm.run(src), Ok(JsValue::Number(100.0))); +} + +#[test] +fn values_built_ins_hold_survive_collections() { + // A small heap makes the callbacks' garbage start collections while the results are built. + let mut realm = small(Limits { heap_bytes: 2 << 20, ..Limits::default() }); + let src = "function junk() { var t = []; for (var j = 0; j < 200; j++) t[j] = {j: j}; return t.length; } \ + var src = []; for (var i = 0; i < 300; i++) src[i] = i; \ + var m = src.map(function (x) { junk(); return {v: x}; }); \ + var f = src.filter(function (x) { junk(); return x % 2; }); \ + var r = src.reduce(function (acc, x) { junk(); return {sum: acc.sum + x}; }, {sum: 0}); \ + var s = src.slice().sort(function (a, b) { junk(); return b - a; }); \ + var j = JSON.parse(JSON.stringify(m), function (k, v) { junk(); return v; }); \ + m[299].v + f.length + r.sum + s[0] + j[299].v + j.length"; + assert_eq!(realm.run(src), Ok(JsValue::Number(299.0 + 150.0 + 44_850.0 + 299.0 + 299.0 + 300.0))); + assert!(realm.interp().heap.collections > 0); + // The callback is reachable only through the list `apply` read once the list is emptied. + let src = "var list = [function (x) { list.length = 0; junk(); return {v: x}; }]; \ + var r = Array.prototype.map.apply(src, list); \ + var g = Array.prototype.map.bind(src, function (x) { junk(); return {v: x}; }); g = g(); \ + r[299].v + g[299].v"; + assert_eq!(realm.run(src), Ok(JsValue::Number(598.0))); +} + +#[test] +fn fresh_values_survive_a_collection_while_they_are_stored() { + // Below 1 MiB the only collections run at the heap limit, many of them while a built-in + // reserves room for a value that a callback has just made and nothing else holds. + let limits = Limits { heap_bytes: 768 << 10, ..Limits::default() }; + let src = "var bad = 0; \ + for (var n = 0; n < 2000; n++) { \ + var m = [1, 2, 3, 4, 5, 6, 7, 8].map(function (x) { var g = {x: x}; return {v: x}; }); \ + var f = [1, 2, 3, 4].filter(function () { return true; }).map(function (x) { return [x]; }); \ + for (var i = 0; i < 8; i++) if (!m[i] || m[i].v !== i + 1) bad++; \ + for (var i = 0; i < 4; i++) if (!f[i] || f[i][0] !== i + 1) bad++; \ + } bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + // Getters make a new object for each read, which `shift` and `sort` then store. + let src = "function fresh(v) { var g = {x: v}; return {v: v}; } \ + function set(i) { return function (x) { Object.defineProperty(this, i, {value: x, writable: true, enumerable: true, configurable: true}); }; } \ + var p = {}; \ + for (var i = 0; i < 4; i++) Object.defineProperty(p, i, {get: (function (v) { return function () { return fresh(v); }; })(4 - i), set: set(i)}); \ + var bad = 0; \ + for (var n = 0; n < 1000; n++) { \ + var o = Object.create(p); o.length = 4; Array.prototype.shift.call(o); \ + if (!o[0] || o[0].v !== 3 || o[2].v !== 1) bad++; \ + var s = Object.create(p); s.length = 4; Array.prototype.sort.call(s, function (a, b) { return a.v - b.v; }); \ + for (var i = 0; i < 4; i++) if (!s[i] || s[i].v !== i + 1) bad++; \ + } \ + var q = {}; \ + for (var i = 0; i < 4; i++) Object.defineProperty(q, i, {get: (function (v) { return function () { return {v: v}; }; })(4 - i), set: function () {}}); \ + for (var n = 0; n < 20000; n++) { \ + var u = Object.create(q); u.length = 4; Array.prototype.unshift.call(u, 0, 0, 0, 0); \ + for (var i = 4; i < 8; i++) if (!u.hasOwnProperty(i) || u[i].v !== 8 - i) bad++; \ + } \ + for (var n = 0; n < 5000; n++) { var t = Object.create(q); t.length = 4; Array.prototype.sort.call(t); } \ + bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + // The `z` objects change each round's garbage, so the collections fall at different points. + let src = "var bad = 0; \ + for (var n = 0; n < 10000; n++) { \ + for (var z = 0; z < n % 7; z++) var g = {z: z}; \ + var j = JSON.parse('[1, 2, 3, 4, 5, 6, 7, 8]', function (k, v) { return k === '' ? v : {v: v}; }); \ + for (var i = 0; i < 8; i++) if (!j[i] || j[i].v !== i + 1) bad++; \ + } bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + let src = "var k = ''; for (var i = 0; i < 300; i++) k += 'k'; \ + var o = {}; o[k] = {toJSON: function () { var g = {x: 1}; return {a: 1}; }}; \ + var want = '{\"' + k + '\":{\"a\":1}}'; \ + var bad = 0; for (var n = 0; n < 4000; n++) if (JSON.stringify(o) !== want) bad++; bad"; + let mut realm = small(limits); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); +} + +// ---- errors ---- + +#[test] +fn error_objects_have_names_messages_and_to_string() { + assert_eq!(string("new TypeError('bad').toString()"), "TypeError: bad"); + assert_eq!(string("RangeError('r').name + Error().message + (new Error(undefined).hasOwnProperty('message') ? 1 : 0)"), "RangeError0"); + truth("var e = new SyntaxError('s'); e instanceof SyntaxError && e instanceof Error && Object.prototype.toString.call(e) === '[object Error]'"); + assert_eq!(string("var e = new Error('m'); e.name = ''; e.toString()"), "m"); + assert_eq!(string("Error.prototype.toString.call({name: 'N'})"), "N"); + truth("URIError.prototype.name === 'URIError' && Object.getPrototypeOf(EvalError.prototype) === Error.prototype && Error.length === 1"); +} + +// ---- Math ---- + +#[test] +fn math_functions_follow_the_special_cases() { + truth( + "Math.max() === -Infinity && Math.min() === Infinity && isNaN(Math.max(1, NaN, 3)) && 1 / Math.max(-0, 0) === Infinity && 1 / Math.min(0, -0) === -Infinity", + ); + truth("Math.round(2.5) === 3 && Math.round(-2.5) === -2 && 1 / Math.round(-0.4) === -Infinity && Math.round(0.49999999999999994) === 0"); + truth("isNaN(Math.pow(1, NaN)) && isNaN(Math.pow(-1, Infinity)) && Math.pow(NaN, 0) === 1 && Math.pow(2, -1) === 0.5"); + truth("Math.abs(-3) === 3 && Math.floor(-1.5) === -2 && Math.ceil(-1.5) === -1 && 1 / Math.ceil(-0.5) === -Infinity"); + truth("Math.sqrt(-1) !== Math.sqrt(-1) && Math.atan2(0, -0) === Math.PI && Math.exp(0) === 1 && Math.log(Math.E) === 1"); + truth("Math.abs(Math.sin(Math.PI)) < 1e-15 && Math.cos(0) === 1 && Math.tan(0) === 0 && Math.asin(1) === Math.PI / 2"); + truth("var ok = true; for (var i = 0; i < 1000; i++) { var r = Math.random(); if (!(r >= 0 && r < 1)) ok = false; } ok"); + truth("Math.SQRT2 * Math.SQRT1_2 > 0.9999 && Math.LN2 > 0.69 && Math.LOG10E > 0.43 && Object.prototype.toString.call(Math) === '[object Math]'"); + truth("var n = 0; Math.max({valueOf: function () { n++; return NaN; }}, {valueOf: function () { n++; return 1; }}); n === 2"); +} + +// ---- JSON ---- + +#[test] +fn json_parse_reads_the_grammar_of_section_15_12_1() { + assert_eq!(num("var v = JSON.parse(' {\"a\": [1, -2.5e1, true, null, \"x\\\\u0041\\\\n\"], \"b\": {}} '); v.a[1] + v.a.length"), -20.0); + assert_eq!(num("JSON.parse('\"\\\\ud800\"').charCodeAt(0)"), 55296.0); + truth("JSON.parse('[]').length === 0 && JSON.parse('0') === 0 && 1 / JSON.parse('-0') === -Infinity && JSON.parse('{\"a\":1,\"a\":2}').a === 2"); + for bad in ["'{a: 1}'", "'[1,]'", "'01'", "'1.'", "'.5'", "'\"\\t\"'", "\"'x'\"", "'[1] 2'", "''", "'+1'", "'\"\\\\x\"'", "'{\"a\" 1}'"] { + throws(&format!("JSON.parse({bad})"), "SyntaxError"); + } + // The reviver sees every value bottom-up and can delete or replace it. + assert_eq!( + string( + "var seen = []; var v = JSON.parse('{\"a\": [1, 2], \"b\": 3}', function (k, v) { seen.push(k); return k === 'b' ? undefined : v; }); seen.join() + ':' + ('b' in v)" + ), + "0,1,a,b,:false" + ); + assert_eq!(num("JSON.parse('[1, 2]', function (k, v) { return typeof v === 'number' ? v * 10 : v; })[1]"), 20.0); +} + +#[test] +fn json_stringify_writes_values_with_replacers_and_indentation() { + assert_eq!( + string("JSON.stringify({a: [1, 'x', null, undefined, function () {}], b: undefined, c: true})"), + "{\"a\":[1,\"x\",null,null,null],\"c\":true}" + ); + assert_eq!(string("JSON.stringify('\\u0001\"\\\\\\n')"), "\"\\u0001\\\"\\\\\\n\""); + assert_eq!(string("JSON.stringify([NaN, Infinity, -0, 1e21])"), "[null,null,0,1e+21]"); + assert_eq!(run("JSON.stringify(undefined)"), JsValue::Undefined); + assert_eq!(run("JSON.stringify(function () {})"), JsValue::Undefined); + assert_eq!(string("JSON.stringify({a: 1, b: [1, 2], c: {}}, null, 2)"), "{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ],\n \"c\": {}\n}"); + assert_eq!(string("JSON.stringify([1], null, 'abcdefghijkl')"), "[\nabcdefghij1\n]"); + assert_eq!(string("JSON.stringify({a: 1, b: 2, c: 3}, ['c', 'a', 'c', 1])"), "{\"c\":3,\"a\":1}"); + assert_eq!(string("JSON.stringify({a: 1, b: 'x'}, function (k, v) { return typeof v === 'number' ? v + 1 : v; })"), "{\"a\":2,\"b\":\"x\"}"); + assert_eq!(string("JSON.stringify({toJSON: function (k) { return 'k=' + k; }})"), "\"k=\""); + assert_eq!(string("JSON.stringify({d: {toJSON: function (k) { return k; }}})"), "{\"d\":\"d\"}"); + assert_eq!(string("JSON.stringify([Object(false), Object(true)])"), "[false,true]"); + // Number and String objects go through ToNumber and ToString, which call valueOf and toString. + assert_eq!(string("var n = Object(5); n.valueOf = function () { return 7; }; JSON.stringify([n])"), "[7]"); + assert_eq!(string("var s = Object('x'); s.toString = function () { return 'y'; }; JSON.stringify({s: s})"), "{\"s\":\"y\"}"); + assert_eq!(string("JSON.stringify({}, null, 4) + JSON.stringify([], null, 4)"), "{}[]"); + throws("var a = []; a.push(a); JSON.stringify(a)", "TypeError"); + throws("var o = {}; o.x = {y: o}; JSON.stringify(o)", "TypeError"); + // The same object twice, not nested in itself, is not a cycle. + assert_eq!(string("var x = {}; JSON.stringify([x, x])"), "[{},{}]"); +} + +#[test] +fn json_stringify_unwraps_number_string_and_boolean_objects() { + assert_eq!(string("JSON.stringify(Object(3))"), "3"); + assert_eq!(string("JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("JSON.stringify(Object(true))"), "true"); + assert_eq!(string("JSON.stringify({n: Object(-0), m: Object(NaN), s: Object('')})"), "{\"n\":0,\"m\":null,\"s\":\"\"}"); + assert_eq!(string("JSON.stringify([Object.getPrototypeOf(Object(3)), Object.getPrototypeOf(Object('abc'))])"), "[0,\"\"]"); + // The space argument (step 5). + assert_eq!(string("JSON.stringify([1], null, Object(2))"), "[\n 1\n]"); + assert_eq!(string("JSON.stringify([1], null, Object('--'))"), "[\n--1\n]"); + // Replacer list entries (step 4.b.ii). + assert_eq!(string("JSON.stringify({a: 1, 3: 2, b: 3}, [Object('a'), Object(3)])"), "{\"a\":1,\"3\":2}"); + // Methods on Object.prototype do not reach the objects; methods on their own prototypes do. + assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("Object.prototype.valueOf = function () { return 9; }; JSON.stringify(Object(3))"), "3"); + assert_eq!(string("Object.getPrototypeOf(Object(1)).valueOf = function () { return 4; }; JSON.stringify(Object(3))"), "4"); + assert_eq!(string("Object.getPrototypeOf(Object('')).toString = function () { return 'q'; }; JSON.stringify(Object('abc'))"), "\"q\""); +} + +#[test] +fn json_parse_counts_a_string_while_it_reserves_the_property_for_it() { + // The limits around the parsed value's size include one where the reservation for the string's + // property is what starts a collection. The text is a concatenation, so the parse also flattens + // it. Whenever the parse fits, the heap total counts everything that is live. + let setup = |text: &str| format!("var s = new Array(4097).join('x'); var text = {text}; 0"); + for text in ["'[\"' + s + '\"]'", "'{\"k\":\"' + s + '\"}'"] { + let setup = setup(text); + let parse = "var a = JSON.parse(text); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + assert_eq!(measure.run(parse), Ok(JsValue::Number(0.0))); + let full = measure.interp().heap.bytes; + let (mut fits, mut over) = (0, 0); + for heap_bytes in (full - 8192..=full + 256).step_by(4) { + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0))); + realm.interp().collect_garbage(); + match realm.run(parse) { + Ok(_) => { + let counted = realm.interp().heap.bytes; + let live = realm.collect_garbage(); + assert!(counted >= live, "{text}: {counted} bytes counted with {live} live under a limit of {heap_bytes}"); + fits += 1; + } + Err(RunError { kind: RunErrorKind::Limit(LimitKind::Heap), .. }) => over += 1, + Err(e) => panic!("{text}: {e:?}"), + } + } + assert!(fits > 0 && over > 0, "{text}: {fits} limits fit and {over} did not"); + } +} + +#[test] +fn json_parse_keeps_its_text_counted_for_the_whole_parse() { + // The text exists only as the ToString result of the argument, and the parsed value needs a + // collection. Without the text the value fits under the limit; with it the value does not. + let setup = "var ws = new Array(8193).join(' '); var items = new Array(257).join('{},'); \ + var o = {toString: function () { return ws + '[' + items + '{}]'; }}; 0"; + let parse = "var a = JSON.parse(o); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + assert_eq!(measure.run(parse), Ok(JsValue::Number(0.0))); + let full = measure.interp().heap.bytes; + let text = 2 * (8192 + 2 + 3 * 256 + 2); + let mut realm = small(Limits { heap_bytes: full - text / 2, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + realm.interp().collect_garbage(); + assert_eq!(limit(&mut realm, parse), LimitKind::Heap); + // The text is released before the reviver runs, so the reviver can use the room it took. + let setup = "var ws = new Array(65537).join(' '); var o = {toString: function () { return ws + '1'; }}; 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + let heap_bytes = measure.collect_garbage() + 2 * 65537 + (16 << 10); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("JSON.parse(o, function () { return new Array(40001).join('x').length; })"), Ok(JsValue::Number(40000.0))); +} + +#[test] +fn json_parse_counts_names_that_wait_for_their_values() { + // An unterminated text: every name read waits for a value that never comes, so nothing but + // the parser holds them, and the names together pass the limit before the text ends. + let setup = "var k = new Array(2049).join('k'); var text = new Array(33).join('{\"' + k + '\":'); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + let heap_bytes = measure.collect_garbage() + (96 << 10); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "JSON.parse(text)"), LimitKind::Heap); + // A complete text gives its names back as it stores them, so one that fits parses. + let mut realm = small(Limits { heap_bytes: heap_bytes + (64 << 10), ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("var a = JSON.parse('{\"' + k + '\":{\"' + k + 'x\":1}}'); 0"), Ok(JsValue::Number(0.0))); +} + +#[test] +fn deep_json_nesting_needs_no_native_stack() { + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(|| { + let mut realm = Realm::new(); + realm.run( + "var depth = 100000; var s = ''; for (var i = 0; i < depth; i++) s += '['; for (i = 0; i < depth; i++) s += ']'; \ + var v = JSON.parse(s); var back = JSON.stringify(v); var n = 0; JSON.parse(s, function (k, v) { n++; return v; }); \ + back.length === 2 * depth && n === depth", + ) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::Bool(true))); +} + +// ---- global functions ---- + +#[test] +fn global_number_functions() { + truth("parseInt(' 0x1F') === 31 && parseInt('-12px') === -12 && parseInt('z', 36) === 35 && isNaN(parseInt('', 10)) && isNaN(parseInt('1', 1))"); + truth( + "parseInt('011') === 11 && parseInt('11', 2) === 3 && parseInt('0x10', 16) === 16 && isNaN(parseInt('0x', 16)) && parseInt('12', 3.9) === 5", + ); + truth("parseInt('123456789012345678901234567890') === 1.2345678901234568e29 && 1 / parseInt('-0') === -Infinity"); + truth( + "parseFloat(' 3.5e2x') === 350 && parseFloat('-.5') === -0.5 && parseFloat('Infinityx') === Infinity && isNaN(parseFloat('e5')) && parseFloat('1e') === 1", + ); + truth("isNaN('x') && !isNaN('1') && isFinite('1') && !isFinite(Infinity) && !isFinite(NaN)"); + truth("NaN !== NaN && Infinity === 1 / 0 && undefined === void 0 && !Object.getOwnPropertyDescriptor(this, 'NaN').writable"); +} + +#[test] +fn uri_functions_encode_and_decode_utf8() { + assert_eq!(string("encodeURIComponent('a b&c/\\u00e9\\ud83d\\ude00')"), "a%20b%26c%2F%C3%A9%F0%9F%98%80"); + assert_eq!(string("encodeURI('http://x.y/a b?c=d#e')"), "http://x.y/a%20b?c=d#e"); + assert_eq!(string("decodeURIComponent('a%20b%26%C3%A9%F0%9F%98%80') === 'a b&\\u00e9\\ud83d\\ude00' ? 'ok' : 'no'"), "ok"); + assert_eq!(string("decodeURI('%3B%2F%20%23')"), "%3B%2F %23"); + for bad in [ + "encodeURI('\\ud800')", + "encodeURIComponent('\\udc00x')", + "decodeURI('%')", + "decodeURI('%C3')", + "decodeURI('%C0%80')", + "decodeURI('%ED%A0%80')", + "decodeURI('%FF')", + ] { + throws(bad, "URIError"); + } +} + +#[test] +fn escape_and_unescape_follow_annex_b() { + assert_eq!(string("escape('a b+/@\\u00e9\\u0100')"), "a%20b+/@%E9%u0100"); + assert_eq!(string("unescape('a%20b%E9%u0100%zz%u12') === 'a b\\u00e9\\u0100%zz%u12' ? 'ok' : 'no'"), "ok"); +} + +// ---- robustness ---- + +/// Calls of built-ins with arguments of every kind. +const CALLS: &[&str] = &[ + "[1,,3].join(X)", + "[3,1,2].sort(X)", + "[1,2,3].splice(X, X, X)", + "[1,2].slice(X, X)", + "[].concat(X, [X])", + "[1,2,3].map(X)", + "[1,2].reduce(X, X)", + "[1,2].indexOf(X, X)", + "[1,2].lastIndexOf(X, X)", + "Array.prototype.push.call(X, X)", + "Array.prototype.unshift.call(X, X)", + "Array.prototype.reverse.call(X)", + "Array(X)", + "Object.keys(X)", + "Object.freeze(X)", + "Object.defineProperty(X, X, X)", + "Object.create(X, X)", + "Object.getOwnPropertyNames(X)", + "JSON.stringify(X, X, X)", + "JSON.parse(X, X)", + "Math.max(X, X)", + "Math.pow(X, X)", + "parseInt(X, X)", + "parseFloat(X)", + "decodeURIComponent(X)", + "encodeURI(X)", + "unescape(X)", + "escape(X)", + "Function.prototype.bind.call(X, X)", +]; + +const ARGS: &[&str] = &[ + "undefined", + "null", + "0", + "-1", + "4294967295", + "NaN", + "'%E0'", + "'[1,{\"a\":2}]'", + "''", + "[]", + "{length: 4294967295}", + "{}", + "function (a, b) { return b - a; }", + "{valueOf: function () { throw 1; }}", + "{get length() { return 3; }}", + "Object.freeze([1, 2])", + "{value: 1, get: function () {}}", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn arbitrary_built_in_calls_never_panic(call in 0..CALLS.len(), args in proptest::collection::vec(0..ARGS.len(), 3)) { + let mut src = CALLS[call].to_string(); + for a in &args { + src = src.replacen('X', ARGS[*a], 1); + } + let src = src.replace('X', "1"); + let mut realm = small(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + if let Err(e) = realm.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src}: {e:?}"); + } + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 69b0b4f40..1b2099fe1 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -170,11 +170,14 @@ pub(crate) enum NativeKind { Eval, } +/// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section +/// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. #[derive(Clone)] pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, + Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } /// A sloppy mode `arguments` object's link to its function's parameters (section 10.6): index @@ -204,8 +207,8 @@ pub(crate) enum Class { Function(Func), Error, Arguments(Box), - /// A `Boolean` object made by ToObject. - Boolean, + /// A `Boolean` object made by ToObject, with its primitive value. + Boolean(bool), /// A `Number` object and its number. Number(f64), String(Str), @@ -213,18 +216,24 @@ pub(crate) enum Class { /// A `Date` object and its time value. Date(f64), ForIn(Box), + /// The `Math` object (section 15.8). + Math, + /// The `JSON` object (section 15.12). + Json, } impl Class { /// The `[[Class]]` name `Object.prototype.toString` reports. pub(crate) fn name(&self) -> &'static str { match self { + Class::Math => "Math", + Class::Json => "JSON", Class::Ordinary | Class::ForIn(_) => "Object", Class::Array { .. } => "Array", Class::Function(_) => "Function", Class::Error => "Error", Class::Arguments(_) => "Arguments", - Class::Boolean => "Boolean", + Class::Boolean(_) => "Boolean", Class::Number(_) => "Number", Class::String(_) => "String", Class::RegExp(_) => "RegExp", @@ -292,6 +301,8 @@ pub(crate) const OBJECT_BYTES: usize = 96; pub(crate) const ENV_BYTES: usize = 64; /// The estimated bytes of a rope node. pub(crate) const ROPE_BYTES: usize = 48; +/// The estimated bytes of each argument a bound function holds. +pub(crate) const BOUND_ARG_BYTES: usize = 24; /// The estimated bytes of a piece of code without its instructions, tables and strings. const CODE_BYTES: usize = 384; @@ -308,6 +319,10 @@ pub(crate) struct Heap { pub(crate) bytes: usize, /// How many collections have run. pub(crate) collections: usize, + /// Counts the properties whose names start with a digit (the array indices among them) added + /// to any object, so an operation that lists the indices an object has can tell whether a + /// script added one since. + pub(crate) index_epoch: u64, } impl Heap { @@ -524,11 +539,20 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u Prop::Accessor { get, set, .. } => work.extend(get.iter().chain(set.iter()).map(|f| f.0)), } } + let mut extra = 0; match &o.class { Class::Function(Func::Closure { env, code }) => { work.push(env.0); mark_code(code, strings); } + Class::Function(Func::Bound { target, this, args }) => { + work.push(target.0); + mark_value(this, work, strings); + for a in args.iter() { + mark_value(a, work, strings); + } + extra = BOUND_ARG_BYTES * args.len(); + } Class::Arguments(map) => work.push(map.env.0), Class::String(s) => strings.add(s), Class::RegExp(re) => { @@ -542,7 +566,7 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u } _ => {} } - OBJECT_BYTES + PROP_BYTES * o.props.len() + OBJECT_BYTES + PROP_BYTES * o.props.len() + extra } fn trace_env(e: &Env, work: &mut Vec, strings: &mut StringSizes) -> usize { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 9e72285f1..b110a955b 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -164,6 +164,17 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The state of `Math.random`'s generator. + pub(crate) random: u64, + /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a + /// cycle back to one of them joins as an empty string. + pub(crate) join_stack: Vec, + /// Bytes of the string buffers native functions are building outside the heap. They are + /// charged to `heap.bytes` and added back after each collection, and a native call's + /// buffers are released when it returns. + pub(crate) held_bytes: usize, + /// Why installing the built-ins failed; a realm with this set runs no script. + pub(crate) install_error: Option, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. @@ -239,6 +250,15 @@ fn index_keys_bytes(len: usize) -> usize { total } +/// A seed for `Math.random` that differs from realm to realm, from the standard library's +/// randomly keyed hasher; never zero. +fn random_seed() -> u64 { + use std::hash::{BuildHasher, Hasher}; + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(0x9e37_79b9_7f4a_7c15); + h.finish() | 1 +} + /// The estimated bytes of a binding or property named by `len` code units. fn binding_bytes(len: usize) -> usize { PROP_BYTES + string_bytes(len) @@ -341,6 +361,10 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + random: random_seed(), + join_stack: Vec::new(), + held_bytes: 0, + install_error: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), }; @@ -515,7 +539,7 @@ impl Interp { self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. self.held.released.set(0); - self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get()); + self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get()); } /// Runs frames until the boundary frame on top returns, and returns its value. @@ -1058,13 +1082,219 @@ impl Interp { *length = i + 1; } let name_len = k.len(); + let index = new && k.first().is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); o.props.insert(k, prop); if new { self.heap.bytes += PROP_BYTES + string_bytes(name_len); } + if index { + self.heap.index_epoch = self.heap.index_epoch.wrapping_add(1); + } Ok(()) } + /// [[DefineOwnProperty]] (section 8.12.9, with the array rules of section 15.4.5.1 and the + /// arguments object rules of section 10.6). Returns whether the property was defined; with + /// `throw` a rejection throws a `TypeError` instead. + pub(crate) fn define_own_property(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult { + match &self.object(id)?.class { + Class::Array { length, length_writable } => { + let (length, length_writable) = (*length, *length_writable); + if is(&k, "length") { + return self.define_array_length(id, desc, throw); + } + if let Some(i) = array_index(&k) + && i >= length + && !length_writable + { + return self.reject_bool(throw, &k, "the array's length is read-only"); + } + self.define_ordinary(id, k, desc, throw) + } + Class::Arguments(map) => { + let mapped = array_index(&k).and_then(|i| map.names.get(i as usize)).and_then(Option::as_ref).cloned(); + if !self.define_ordinary(id, k.clone(), desc, false)? { + return self.reject_bool(throw, &k, "the property cannot be redefined"); + } + if let Some(name) = mapped { + let env = match &self.object(id)?.class { + Class::Arguments(map) => Some(map.env), + _ => None, + }; + if let Some(v) = desc.value.clone().filter(|_| !desc.is_accessor()) + && let Some(env) = env + && let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) + && let Some(b) = bindings.get_mut(&name) + { + b.value = v; + } + if (desc.is_accessor() || desc.writable == Some(false)) + && let Class::Arguments(map) = &mut self.object_mut(id)?.class + && let Some(slot) = array_index(&k).and_then(|i| map.names.get_mut(i as usize)) + { + *slot = None; + } + } + Ok(true) + } + _ => self.define_ordinary(id, k, desc, throw), + } + } + + fn reject_bool(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult { + if throw { + let message = format!("cannot define property '{}': {why}", String::from_utf16_lossy(k)); + return Err(self.type_error(&message)); + } + Ok(false) + } + + /// The default [[DefineOwnProperty]] (section 8.12.9). + fn define_ordinary(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult { + let current = self.get_own_property(id, &k)?; + let o = self.object(id)?; + let virtual_string = matches!(&o.class, Class::String(s) if is(&k, "length") || array_index(&k).is_some_and(|i| (i as usize) < s.len())); + let Some(current) = current else { + if !o.extensible { + return self.reject_bool(throw, &k, "the object is not extensible"); + } + // The object and the descriptor's values stay rooted while the heap is checked. + let mut roots = vec![Value::Object(id)]; + roots.extend(desc.value.iter().cloned()); + roots.extend(desc.get.flatten().into_iter().chain(desc.set.flatten()).map(Value::Object)); + self.rooted(&roots, |i| i.reserve_heap(binding_bytes(k.len())))?; + let flags = desc.flags_or(0); + let prop = if desc.is_accessor() { + Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: flags & !WRITABLE } + } else { + Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags } + }; + self.define(id, k, prop)?; + return Ok(true); + }; + let Some(merged) = merge_desc(¤t, desc) else { return self.reject_bool(throw, &k, "the property cannot be redefined") }; + // An index or the length of a String object cannot change, so a definition that passes + // the checks leaves it as it is. + if virtual_string { + return Ok(true); + } + self.define(id, k, merged)?; + Ok(true) + } + + /// Defining an array's `length` (section 15.4.5.1, step 3). + fn define_array_length(&mut self, id: ObjId, desc: &Desc, throw: bool) -> JsResult { + let length_key = key("length"); + let Some(v) = desc.value.clone() else { + let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") }; + let Some(merged) = merge_desc(¤t, desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") }; + if let Class::Array { length_writable, .. } = &mut self.object_mut(id)?.class { + *length_writable = merged.flags() & WRITABLE != 0; + } + return Ok(true); + }; + let new = to_uint32(self.to_number(&v)?); + if f64::from(new) != self.to_number(&v)? { + return Err(self.throw(ErrorType::Range, "invalid array length")); + } + let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") }; + let Class::Array { length: old, length_writable } = self.object(id)?.class else { return internal("not an array") }; + let new_desc = Desc { value: Some(Value::Number(f64::from(new))), ..desc.clone() }; + if new >= old { + let Some(merged) = merge_desc(¤t, &new_desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") }; + if let Class::Array { length, length_writable } = &mut self.object_mut(id)?.class { + *length = new; + *length_writable = merged.flags() & WRITABLE != 0; + } + return Ok(true); + } + if !length_writable { + return self.reject_bool(throw, &length_key, "it is read-only"); + } + let checked = Desc { writable: None, ..new_desc }; + if merge_desc(¤t, &checked).is_none() { + return self.reject_bool(throw, &length_key, "the length cannot be redefined"); + } + let read_only = desc.writable == Some(false); + if !self.shrink_array(id, new, read_only)? { + return self.reject_bool(throw, &length_key, "an element cannot be deleted"); + } + Ok(true) + } + + /// Sets an array's length to `new`, deleting the elements at and past it from the last one + /// down (section 15.4.5.1, step 3.l). An element that cannot be deleted stops it, leaving + /// the length one past that element, and gives `false`. Each property takes a unit of fuel + /// before anything is removed. With `read_only` the length is made read-only afterwards + /// either way. + fn shrink_array(&mut self, id: ObjId, new: u32, read_only: bool) -> JsResult { + let Class::Array { length: old, .. } = self.object(id)?.class else { return internal("not an array") }; + let mut ok = true; + let mut length = new; + if new < old { + for _ in 0..self.object(id)?.props.len() { + self.use_fuel()?; + } + let o = self.object_mut(id)?; + let mut doomed: Vec<(u32, Key)> = + o.props.iter().filter_map(|(k, _)| array_index(k).filter(|&i| i >= new).map(|i| (i, k.clone()))).collect(); + doomed.sort_unstable_by_key(|d| std::cmp::Reverse(d.0)); + for (i, k) in doomed { + if o.props.get(&k).is_some_and(|p| !p.configurable()) { + length = i + 1; + ok = false; + break; + } + o.props.remove(&k); + } + } + if let Class::Array { length: l, length_writable } = &mut self.object_mut(id)?.class { + *l = length; + if read_only { + *length_writable = false; + } + } + Ok(ok) + } + + /// The names of an object's own properties (section 15.2.3.4): a String object's indices and + /// `length` first, then the rest in the order they were added, which is the order `for-in` + /// visits them, then an array's `length`. Each name takes a unit of fuel, and the heap for the + /// list is checked first. + pub(crate) fn own_keys(&mut self, id: ObjId) -> JsResult> { + let o = self.object(id)?; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + let is_string = matches!(o.class, Class::String(_)); + let is_array = matches!(o.class, Class::Array { .. }); + let count = o.props.len().saturating_add(string_len).saturating_add(usize::from(is_string || is_array)); + self.reserve_heap(index_keys_bytes(string_len).saturating_add(count.saturating_mul(KEY_ENTRY_BYTES)))?; + let mut out = Vec::with_capacity(count); + for i in 0..string_len { + self.use_fuel()?; + out.push(index_key(u32::try_from(i).unwrap_or(u32::MAX))); + } + if is_string { + out.push(key("length")); + } + for _ in 0..self.object(id)?.props.len() { + self.use_fuel()?; + } + out.extend(self.object(id)?.props.keys()); + if is_array { + out.push(key("length")); + } + Ok(out) + } + + /// Runs `f` with `values` kept reachable for any collection it starts. + pub(crate) fn rooted(&mut self, values: &[Value], f: impl FnOnce(&mut Self) -> JsResult) -> JsResult { + let mark = self.temp_roots.len(); + self.temp_roots.extend(values.iter().cloned()); + let r = f(self); + self.temp_roots.truncate(mark); + r + } + fn reject(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult<()> { if throw { let message = format!("cannot assign to property '{}': {why}", String::from_utf16_lossy(k)); @@ -1131,32 +1361,19 @@ impl Interp { } } - /// Assigning to an array's `length` (section 15.4.5.1). + /// Assigning to an array's `length` (section 15.4.5.1). [[Put]] defines the value, so the + /// value is converted once with ToUint32 and once with ToNumber, as in steps 3.c and 3.d. fn set_array_length(&mut self, id: ObjId, v: &Value, throw: bool) -> JsResult<()> { - let n = self.to_number(v)?; - let new = to_uint32(n); - if f64::from(new) != n { + let new = to_uint32(self.to_number(v)?); + if f64::from(new) != self.to_number(v)? { return Err(self.throw(ErrorType::Range, "invalid array length")); } - let o = self.object(id)?; - let Class::Array { length: old, length_writable } = o.class else { return internal("not an array") }; + let Class::Array { length_writable, .. } = self.object(id)?.class else { return internal("not an array") }; if !length_writable { return self.reject(throw, &key("length"), "it is read-only"); } - if new < old { - // Shrinking visits every property: each takes fuel before anything is removed. - for _ in 0..o.props.len() { - self.use_fuel()?; - } - } - let o = self.object_mut(id)?; - let Class::Array { length, .. } = &mut o.class else { return internal("not an array") }; - *length = new; - if new < old { - let doomed: Vec = o.props.keys().into_iter().filter(|k| array_index(k).is_some_and(|i| i >= new)).collect(); - for k in doomed { - o.props.remove(&k); - } + if !self.shrink_array(id, new, false)? { + return self.reject(throw, &key("length"), "an element cannot be deleted"); } Ok(()) } @@ -1576,13 +1793,25 @@ impl Interp { this = args.first().cloned().unwrap_or(Value::Undefined); args = self.list_from_array_like(&list)?; } + Func::Bound { target, this: bound_this, args: bound_args } => { + self.use_fuel()?; + let mut all = Vec::with_capacity(bound_args.len().saturating_add(args.len())); + all.extend(bound_args.iter().cloned()); + all.append(&mut args); + args = all; + this = bound_this; + f = Value::Object(target); + } Func::Native { f: native, .. } => { - // A list from `apply` is not on the operand stack, and a native function can run - // JavaScript that collects, so the arguments stay rooted for the call. + // A list from `apply` and the arguments a bound function adds are not on the + // operand stack, and a native function can run JavaScript that collects, so the + // arguments stay rooted for the call. let mark = self.temp_roots.len(); self.temp_roots.push(this.clone()); self.temp_roots.extend(args.iter().cloned()); + let held = self.held_bytes; let r = native(self, this, &args, false); + self.held_bytes = held; self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } @@ -1660,8 +1889,17 @@ impl Interp { let n = argc as usize; let start = self.stack.len().checked_sub(n + 1).filter(|&s| s >= self.base()).ok_or_else(|| Abrupt::Internal("new below the stack".into()))?; - let f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); - let args: Vec = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + let mut f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); + let mut args: Vec = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + // A bound function constructs its target with its arguments first (section 15.3.4.5.2); + // the bound function on the stack keeps them reachable. + while let Some(Func::Bound { target, args: bound_args, .. }) = self.func_of(&f) { + self.use_fuel()?; + let mut all = bound_args.to_vec(); + all.append(&mut args); + args = all; + f = Value::Object(target); + } match self.func_of(&f) { Some(Func::Closure { code, env }) => { let Some(callee) = f.as_object() else { return internal("callee is not an object") }; @@ -1674,7 +1912,10 @@ impl Interp { self.enter_function(Entry { callee, code, env, this: Value::Object(obj), args }, Some(obj), false) } Some(Func::Native { f: native, constructor: true, .. }) => { - let v = native(self, Value::Undefined, &args, true)?; + let held = self.held_bytes; + let r = native(self, Value::Undefined, &args, true); + self.held_bytes = held; + let v = r?; self.stack.truncate(start); self.push(v) } @@ -2081,7 +2322,7 @@ impl Interp { let (class, proto) = match v { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), - Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), + Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; @@ -2226,7 +2467,13 @@ impl Interp { if !self.is_callable(f) { return Err(self.type_error("the right side of 'instanceof' is not a function")); } - let Some(fid) = f.as_object() else { return Ok(false) }; + let Some(mut fid) = f.as_object() else { return Ok(false) }; + // A bound function answers for its target (section 15.3.4.5.3). + while let Some(Func::Bound { target, .. }) = self.func_of(&Value::Object(fid)) { + self.use_fuel()?; + fid = target; + } + let f = &Value::Object(fid); let Value::Object(mut o) = v.clone() else { return Ok(false) }; let Value::Object(proto) = self.get_from(fid, &key("prototype"), f)? else { return Err(self.type_error("the function's prototype is not an object")); @@ -2301,6 +2548,107 @@ pub(crate) enum CompileError { Internal(String), } +/// A property descriptor (section 8.10): each field is `None` when it is absent. `get` and `set` +/// are `Some(None)` when present as `undefined`. +#[derive(Clone, Default, Debug)] +pub(crate) struct Desc { + pub(crate) value: Option, + pub(crate) get: Option>, + pub(crate) set: Option>, + pub(crate) writable: Option, + pub(crate) enumerable: Option, + pub(crate) configurable: Option, +} + +impl Desc { + /// A data descriptor with every attribute given. + pub(crate) fn data(value: Value, flags: u8) -> Self { + Desc { + value: Some(value), + writable: Some(flags & WRITABLE != 0), + enumerable: Some(flags & ENUMERABLE != 0), + configurable: Some(flags & CONFIGURABLE != 0), + ..Desc::default() + } + } + + pub(crate) fn is_accessor(&self) -> bool { + self.get.is_some() || self.set.is_some() + } + + pub(crate) fn is_data(&self) -> bool { + self.value.is_some() || self.writable.is_some() + } + + /// The attribute flags the descriptor gives, each absent one taken from `default`. + fn flags_or(&self, default: u8) -> u8 { + let pick = |field: Option, bit: u8| match field { + Some(true) => bit, + Some(false) => 0, + None => default & bit, + }; + pick(self.writable, WRITABLE) | pick(self.enumerable, ENUMERABLE) | pick(self.configurable, CONFIGURABLE) + } +} + +/// SameValue (section 9.12). +pub(crate) fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.is_nan() && y.is_nan()) || (x == y && x.is_sign_negative() == y.is_sign_negative()), + _ => strict_eq(a, b), + } +} + +/// Steps 5 to 12 of [[DefineOwnProperty]] (section 8.12.9) for an existing property: the +/// property after `desc` is applied, or `None` when the change is not allowed. +fn merge_desc(current: &Prop, desc: &Desc) -> Option { + let flags = current.flags(); + let configurable = flags & CONFIGURABLE != 0; + if !configurable { + if desc.configurable == Some(true) { + return None; + } + if desc.enumerable.is_some_and(|e| e != (flags & ENUMERABLE != 0)) { + return None; + } + } + let attrs = |base: u8, writable_bit: bool| { + let f = desc.flags_or(base); + if writable_bit { f } else { f & !WRITABLE } + }; + match current { + Prop::Data { .. } if desc.is_accessor() => { + if !configurable { + return None; + } + Some(Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: attrs(flags & !WRITABLE, false) }) + } + Prop::Accessor { .. } if desc.is_data() => { + if !configurable { + return None; + } + Some(Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags: attrs(flags & !WRITABLE, true) }) + } + Prop::Data { value, .. } => { + if !configurable && flags & WRITABLE == 0 { + if desc.writable == Some(true) { + return None; + } + if desc.value.as_ref().is_some_and(|v| !same_value(v, value)) { + return None; + } + } + Some(Prop::Data { value: desc.value.clone().unwrap_or_else(|| value.clone()), flags: attrs(flags, true) }) + } + Prop::Accessor { get, set, .. } => { + if !configurable && (desc.get.is_some_and(|g| g != *get) || desc.set.is_some_and(|s| s != *set)) { + return None; + } + Some(Prop::Accessor { get: desc.get.unwrap_or(*get), set: desc.set.unwrap_or(*set), flags: attrs(flags, false) }) + } + } +} + /// The strict equality comparison (section 11.9.6). pub(crate) fn strict_eq(a: &Value, b: &Value) -> bool { match (a, b) { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3c36efb13..b883be432 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -24,12 +24,16 @@ //! deep recursion in a script does not use the native stack. Objects and environments live in an //! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped //! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these -//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with -//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`, -//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a -//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every -//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and -//! [`Realm::set_time_zone`]. Every run is bounded +//! built-ins: the global functions and constants (15.1, with Annex B's `escape` and `unescape`), +//! `Object` (15.2), `Function` with `call`, `apply` and `bind` (15.3), `Array` (15.4), `String` +//! (15.5), `Boolean.prototype`'s `toString` and `valueOf` (15.6.4), `Number` (15.7), `Math` (15.8), +//! `Date` (15.9), `RegExp` (15.10), the error types (15.11) and `JSON` (15.12). The array methods +//! visit only the indices an array or array-like object has, so a sparse array of length 2^32-1 +//! costs as much as its elements, and `JSON.parse`, `JSON.stringify` and the reviver walk use no +//! native recursion. Regular expressions run on a backtracking matcher that keeps its choice points +//! on its own stack and takes fuel for every step; `Date` reads the clock and time zone the host +//! sets with [`Realm::set_clock`] and [`Realm::set_time_zone`]. The prototype methods are writable +//! and configurable, so scripts can replace them. Every run is bounded //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. @@ -178,6 +182,8 @@ impl LineTable { } } +#[cfg(test)] +mod builtins_tests; #[cfg(test)] mod interp_tests; #[cfg(test)] diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index eee516f5b..45d7c08bd 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -170,9 +170,13 @@ impl Realm { } /// Runs `source` as a script in the global environment and returns its completion value (the - /// value of the last expression statement that ran). + /// value of the last expression statement that ran). A realm whose built-ins could not be + /// installed runs nothing and gives an internal error. pub fn run(&mut self, source: &str) -> Result { let interp = &mut self.interp; + if let Some(message) = &interp.install_error { + return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); + } interp.fuel = interp.limits.fuel; interp.source_units = 0; interp.throw_site = None; @@ -188,6 +192,7 @@ impl Realm { interp.stack.clear(); interp.frames.clear(); interp.temp_roots.clear(); + interp.join_stack.clear(); match result { Ok(v) => Ok(interp.to_js_value(&v)), Err(Abrupt::Throw(v)) => { diff --git a/crates/js/tests/json2.rs b/crates/js/tests/json2.rs new file mode 100644 index 000000000..af408a4fe --- /dev/null +++ b/crates/js/tests/json2.rs @@ -0,0 +1,89 @@ +//! Runs `json2.js`, Douglas Crockford's public-domain JSON polyfill, when `cargo xtask test262` has +//! fetched it (it sets `VECTORCRAFT_JSON2`; without it the test looks in `corpus/json2/` and skips +//! when the file is absent). +//! +//! The built-in `JSON` is removed first, so the polyfill defines its own. Each sample value is +//! written by the polyfill and by the built-in `JSON.stringify` (ECMA-262 5.1 section 15.12.3), +//! which must give the same text, and the text is read back by the polyfill's `parse` and by the +//! built-in `JSON.parse` (section 15.12.2), which must give values that write the same text again. +//! The samples leave out U+2028 and U+2029: the polyfill escapes them, and section 15.12.3 does not +//! escape them. +//! +//! The polyfill uses `Date`, `String.prototype.replace` and regular expression `test`. While the +//! interpreter lacks any of them, the test prints that it skipped and passes. + +use std::path::{Path, PathBuf}; + +use vectorcraft_js::{JsValue, Realm}; + +fn json2_file() -> Option { + if let Ok(path) = std::env::var("VECTORCRAFT_JSON2") { + return Some(PathBuf::from(path)); + } + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/json2"); + let mut dirs: Vec = + std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path().join("json2.js"))).filter(|p| p.is_file()).collect(); + dirs.sort(); + dirs.pop() +} + +/// Built-ins the polyfill needs. +const NEEDS: &str = + "typeof Date === 'function' && typeof ''.replace === 'function' && typeof RegExp === 'function' && typeof /a/.test === 'function'"; + +/// Saves the built-in `JSON` and removes it, so the polyfill defines its own. +const BEFORE: &str = "var NATIVE = JSON; delete this.JSON; this.JSON = undefined;"; + +/// Writes and reads each sample with both implementations; gives the first difference, or "ok". +const ROUND_TRIP: &str = r#" +var POLY = JSON; +var samples = [ + 0, -1.5, 1e21, "", "plain", "quote \" backslash \\ newline \n tab \t \u0001 é", + true, false, null, [], {}, [1, [2, [3, []]]], {a: 1, b: [true, null, "x"], c: {d: {}}}, + [undefined, function () {}], {skip: undefined, f: function () {}, keep: 2}, [NaN, Infinity, -Infinity] +]; +var problem = "ok"; +for (var i = 0; i < samples.length && problem === "ok"; i++) { + var poly = POLY.stringify(samples[i]); + var native = NATIVE.stringify(samples[i]); + if (poly !== native) { + problem = "sample " + i + ": json2 wrote " + poly + ", the built-in wrote " + native; + } else if (poly !== undefined) { + if (NATIVE.stringify(POLY.parse(native)) !== native) { + problem = "sample " + i + ": json2 read " + native + " differently"; + } else if (POLY.stringify(NATIVE.parse(poly)) !== poly) { + problem = "sample " + i + ": the built-in read " + poly + " differently"; + } + } +} +var indented = {a: [1, {b: 2}], c: "d"}; +if (problem === "ok" && POLY.stringify(indented, null, 2) !== NATIVE.stringify(indented, null, 2)) { + problem = "indented output differs"; +} +if (problem === "ok" && POLY.stringify(indented, ["c"]) !== NATIVE.stringify(indented, ["c"])) { + problem = "property list output differs"; +} +problem; +"#; + +#[test] +fn json2_polyfill_round_trips_with_the_built_in_json() { + let Some(path) = json2_file() else { + println!("json2: SKIPPED (no json2.js; run cargo xtask test262)"); + return; + }; + let source = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut realm = Realm::new(); + match realm.run(NEEDS) { + Ok(JsValue::Bool(true)) => {} + Ok(JsValue::Bool(false)) => { + println!("json2: SKIPPED (the interpreter lacks Date, String.prototype.replace or RegExp, which json2.js uses)"); + return; + } + other => panic!("json2: the built-in check failed: {other:?}"), + } + match realm.run(&format!("{BEFORE}\n{source}\n{ROUND_TRIP}")) { + Ok(JsValue::String(s)) if s == "ok" => println!("json2: the polyfill and the built-in JSON agree on every sample"), + other => panic!("json2: {other:?}"), + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 6303ab078..a6e16ea4c 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -20,10 +20,13 @@ //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. //! -//! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same -//! way, counting the tests that name an ES5 section apart from the other tests without a feature -//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail when the -//! interpreter panics, when a harness or test file cannot be read, or when no ES5 test runs. +//! A third test runs the tests of the ES5 built-ins the same way: those under the folders +//! [`ES5_BUILT_INS`] names that have an `es5id:` (or, under `annexB/`, any edition's id) and no +//! `features:` tag. Its failures go to `built-ins-failures.txt`. A fourth test runs the tests under +//! `test/built-ins/String`, `Number`, `RegExp` and `Date` the same way, counting the tests that name +//! an ES5 section apart from the other tests without a feature tag; its failures go to +//! `builtins-failures.txt`. The interpreter tests fail when the interpreter panics, when a harness +//! or test file cannot be read, or when no ES5 test runs. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -198,15 +201,37 @@ fn es5_parse_subset() { ); } -/// Globals and method names the interpreter does not provide yet: a test that names one is -/// skipped. -const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegExp parseInt parseFloat isNaN isFinite encodeURI \ - decodeURI encodeURIComponent decodeURIComponent escape unescape print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ - WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array defineProperty defineProperties getOwnPropertyDescriptor \ - getOwnPropertyNames getPrototypeOf create keys freeze isFrozen seal isSealed preventExtensions isExtensible push pop shift unshift \ - slice splice concat join reverse sort indexOf lastIndexOf every some forEach map filter reduce reduceRight charAt charCodeAt \ - fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ - bind now isArray compareArray verifyProperty fnGlobalObject"; +/// Globals and method names the interpreter does not provide yet: a test that names one, in its +/// own source or in a harness file it includes, is skipped. +const MISSING_BUILTINS: &str = "String Number Boolean Date RegExp print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ + WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array charAt charCodeAt fromCharCode substring substr split replace \ + match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential now"; + +/// The directories under `test/built-ins/` and `test/annexB/built-ins/` that hold tests of the +/// ES5 built-ins (ECMA-262 5.1 sections 15.1 to 15.12 and Annex B.2). +const ES5_BUILT_INS: &[&str] = &[ + "built-ins/Object", + "built-ins/Function", + "built-ins/Array", + "built-ins/Error", + "built-ins/NativeErrors", + "built-ins/Math", + "built-ins/JSON", + "built-ins/global", + "built-ins/Infinity", + "built-ins/NaN", + "built-ins/undefined", + "built-ins/parseInt", + "built-ins/parseFloat", + "built-ins/isNaN", + "built-ins/isFinite", + "built-ins/encodeURI", + "built-ins/encodeURIComponent", + "built-ins/decodeURI", + "built-ins/decodeURIComponent", + "annexB/built-ins/escape", + "annexB/built-ins/unescape", +]; /// The names in [`MISSING_BUILTINS`] that `String`, `Number`, `RegExp` and `Date` provide. const TEXT_BUILTINS: &str = "String Number Date RegExp charAt charCodeAt fromCharCode substring substr split replace match search exec test \ @@ -310,11 +335,49 @@ fn run_language_subset(dir: &Path) { } #[test] -fn es5_text_builtins_run() { +fn es5_built_ins_subset_runs() { let Some(dir) = suite_dir() else { println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)"); return; }; + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_built_ins(&dir)).unwrap(); + handle.join().unwrap(); +} + +/// Runs the tests under the folders [`ES5_BUILT_INS`] names, prints the counts and writes the +/// failures to `built-ins-failures.txt` beside the suite. The Annex B tests name the edition they +/// were written for with `esid:`, so they run without an `es5id:`. +fn run_built_ins(dir: &Path) { + let missing = missing_builtins(&[TEXT_BUILTINS]); + let mut all = RunCounts::default(); + for folder in ES5_BUILT_INS { + let root = dir.join("test").join(folder); + assert!(root.is_dir(), "{} is not a test262 checkout", dir.display()); + let (es5, later) = run_tests(dir, &root, &missing, folder.starts_with("annexB")); + for counts in [es5, later] { + all.tests += counts.tests; + all.skipped += counts.skipped; + all.passed += counts.passed; + all.failed += counts.failed; + all.panicked += counts.panicked; + all.failures.push_str(&counts.failures); + } + } + assert!(all.tests > 0, "no ES5 built-ins tests ran under {}", dir.display()); + let report = dir.join("built-ins-failures.txt"); + if let Err(e) = std::fs::write(&report, &all.failures) { + println!("could not write {}: {e}", report.display()); + } + println!("test262 ES5 built-ins subset through the interpreter: {}. Failures: {}", all.summary(), report.display()); + assert_eq!(all.panicked, 0, "the interpreter panicked; see {}", report.display()); +} + +#[test] +fn es5_text_builtins_run() { + let Some(dir) = suite_dir() else { + println!("test262 text built-ins: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_text_builtins(&dir)).unwrap(); handle.join().unwrap(); } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 59b3f973f..12c3d766e 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -295,11 +295,18 @@ impl Headless { Ok(out) } - /// `app.open {path}`: any readable file as a new active document (`document.open`). + /// `app.open {path, importer?, params?}`: any readable file as a new active document + /// (`document.open`, with the importer plug-in `importer` names and its parameters `params`). pub fn open(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; + let mut params = json!({"path": path}); + for k in ["importer", "params"] { + if let Some(v) = p.get(k).filter(|v| !v.is_null()) { + params[k] = v.clone(); + } + } let roots = self.roots.clone(); - file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string()) + file_access::confine(roots.as_ref(), || self.session.execute("document.open", ¶ms)).map_err(|e| e.to_string()) } /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 58a6260be..00ff3a2d7 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -415,3 +415,46 @@ fn document_color_mode_puts_rgb_greys_on_the_black_plate() { let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}})); assert!(bad.is_error, "{bad:?}"); } + +/// `open_file`'s `importer` reaches `document.open`: an SVG opens with the SVG reader without it, +/// and naming an importer that isn't installed is an error that names it. +#[test] +fn open_file_passes_the_importer_on() { + let tools = tool_definitions(); + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert!(open["inputSchema"]["properties"].get("importer").is_some(), "{open}"); + let svg = tmp("importer.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let mut h = Headless::new(); + assert_eq!(text(&call_tool(&mut h, "open_file", &json!({"path": svg})))["format"], "svg"); + let r = call_tool(&mut h, "open_file", &json!({"path": svg, "importer": "plugin.test.mcp.absent"})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("test.mcp.absent"), "{r:?}"); + assert_eq!(h.session.documents().len(), 1); +} + +/// `open_file` sends `app.open` the importer's `params` with the importer. +#[test] +fn open_file_passes_the_importer_s_params_on() { + struct Recording(Vec<(String, Value)>); + impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.push((method.to_string(), params)); + Ok(json!({})) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "recording".into() + } + } + let tools = tool_definitions(); + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert_eq!(open["inputSchema"]["properties"]["params"]["type"], "object", "{open}"); + let mut b = Recording(vec![]); + let args = json!({"path": "/in/a.tup", "importer": "plugin.test.mcp.params", "params": {"density": 7}}); + assert!(!call_tool(&mut b, "open_file", &args).is_error); + let [(method, params)] = &b.0[..] else { panic!("one call: {:?}", b.0) }; + assert_eq!(method, "app.open"); + assert_eq!(params, &args); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 105dca346..616e32aae 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -357,10 +357,17 @@ pub fn tool_definitions() -> Vec { "open_file", "Open file", &format!( - "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read).", + "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads, and `params` gives that importer's parameters.", OPEN_EXTS.join(", .") ), - obj(json!({"path": string("File path")}), &["path"]), + obj( + json!({ + "path": string("File path"), + "importer": string("An importer plug-in's format id, plugin. (see run_command document.formats), which must list the file's extension (default: the built-in format that reads the file, else the first importer by id that lists its extension)"), + "params": {"type": "object", "description": "The importer plug-in's parameters (see run_command plugin.list; default: the defaults its manifest declares)"}, + }), + &["path"], + ), false, ), tool( diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index 57167439f..eabd73e3e 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -191,7 +191,13 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result screenshot(b, a), "open_file" => { let path = absolute_path(b, req_str(a, "path")?)?; - j(b.call("app.open", json!({"path": path}))?) + let mut p = json!({"path": path}); + for k in ["importer", "params"] { + if let Some(v) = a.get(k).filter(|v| !v.is_null()) { + p[k] = v.clone(); + } + } + j(b.call("app.open", p)?) } "save_file" => { let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?}); diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index 9ecc76277..b51fcfdb9 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -75,7 +75,8 @@ pub struct Target<'a> { /// The limits of an export by `plugin`: its own, with the budgets of an export. pub fn limits(plugin: &Plugin) -> Limits { - Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() } + let own = plugin.limits(); + Limits { fuel_base: own.export_fuel, wall_time_ms: own.export_wall_time_ms, ..own.clone() } } /// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against @@ -83,7 +84,7 @@ pub fn limits(plugin: &Plugin) -> Limits { pub fn run(plugin: &Plugin, document: &[u8], params: &Value, target: Target) -> Result { let m = plugin.manifest(); if m.kind != Kind::Exporter { - return Err(Error::Params(format!("`{}` is a {}, not an exporter", m.id, m.kind.name()))); + return Err(Error::Params(format!("`{}` is a plug-in of kind `{}`, not an exporter", m.id, m.kind.name()))); } let mut params = Value::Object(m.resolve_params(params)?); params["_context"] = json!({"mode": "export", "extension": target.extension, "name": target.name}); diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs new file mode 100644 index 000000000..599470e0a --- /dev/null +++ b/crates/plugins/src/import.rs @@ -0,0 +1,184 @@ +//! 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 plug-in of kind `{}`, 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()))); + // A decline is reported as one, also with keys the host doesn't read beside it. + assert_eq!(decode(br#"{"error": "No.", "layers": []}"#), Err(Error::Failed("No.".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 dc0aa0127..407f4c20e 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -22,7 +22,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; in [`safe_mode`] none of them loads or runs. 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. A @@ -34,6 +35,7 @@ pub mod dialog; pub mod effect; pub mod export; pub mod host; +pub mod import; pub mod manifest; pub mod objects; pub mod permissions; @@ -49,7 +51,7 @@ pub use manifest::{Budget, Command, 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 74a7be7de..6bb375024 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; /// Most menu commands a plug-in may add (`commands`). pub const MAX_COMMANDS: usize = 32; @@ -38,6 +38,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 { @@ -46,6 +49,7 @@ impl Kind { Kind::Filter => "filter", Kind::Effect => "effect", Kind::Exporter => "exporter", + Kind::Importer => "importer", } } } @@ -281,8 +285,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`]. @@ -367,6 +371,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())); + } } self.validate_params()?; self.validate_commands() @@ -394,7 +401,7 @@ impl Manifest { if self.commands.is_empty() { return Ok(()); } - if self.kind == Kind::Exporter { + if matches!(self.kind, Kind::Exporter | Kind::Importer) { return Err(Error::Manifest("only a filter or a live effect declares `commands`".into())); } if self.budget == Budget::Heavy { @@ -428,17 +435,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()) { @@ -819,6 +826,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 e4737d601..d36cb65fb 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -53,6 +53,9 @@ 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, /// Report how far a run has got, see that the user asked it to stop and write log lines: the /// host imports of API level 2 ([`crate::host::IMPORTS`]). #[serde(rename = "progress")] @@ -66,12 +69,13 @@ pub enum Permission { } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 8] = [ +pub const ALL: [Permission; 9] = [ Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, + Permission::ImportRead, Permission::Progress, Permission::Settings, Permission::Cache, @@ -90,6 +94,7 @@ impl Permission { Permission::State => "state", Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", + Permission::ImportRead => "import.read", Permission::Progress => "progress", Permission::Settings => "settings", Permission::Cache => "cache", @@ -109,6 +114,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", Permission::Progress => "Show its progress, see Cancel and write log lines", Permission::Settings => "Keep settings of its own", Permission::Cache => "Keep a cache of its own, which you can clear", @@ -128,6 +134,7 @@ 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], } } @@ -143,12 +150,12 @@ impl Permission { } /// 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), } } } @@ -277,12 +284,16 @@ mod tests { assert_eq!(validate(Kind::Filter, false, &[]).unwrap(), []); assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render"])).is_ok()); assert!(validate(Kind::Exporter, false, &list(&["export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Importer, false, &list(&["import.read"])).unwrap(), [Permission::ImportRead]); // Any kind may list `progress`, which no kind implies. assert_eq!(validate(Kind::Filter, false, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]); assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render", "progress"])).is_ok()); assert!(validate(Kind::Effect, true, &list(&["doc.read", "effect.render", "progress"])).is_ok()); assert!(validate(Kind::Exporter, false, &list(&["progress", "export.write", "doc.read"])).is_ok()); - assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))); + assert_eq!(validate(Kind::Importer, false, &list(&["progress", "import.read"])).unwrap(), [Permission::ImportRead, Permission::Progress]); + assert!( + [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)) + ); // So may `settings` and `cache`, which no kind implies either. assert_eq!( validate(Kind::Effect, false, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(), @@ -290,7 +301,7 @@ mod tests { ); assert!(validate(Kind::Exporter, false, &list(&["settings", "export.write", "doc.read"])).is_ok()); assert_eq!(validate(Kind::Filter, false, &list(&["cache"])).unwrap(), [Permission::Cache]); - for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] { + for kind in [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer] { for commands in [false, true] { assert!(HOST.iter().all(|p| p.applies(kind, commands) && !Permission::implied(kind).contains(p))); } @@ -316,6 +327,10 @@ mod tests { (Kind::Exporter, false, &["export.write"], "needs the permission `doc.read`"), (Kind::Filter, false, &["cache", "cache"], "`cache` is listed twice"), (Kind::Filter, false, &["storage"], "unknown permission `storage`"), + (Kind::Importer, false, &["import.read", "doc.read"], "`doc.read` doesn't apply to a plug-in of kind `importer`"), + (Kind::Importer, true, &["import.read", "doc.write"], "`doc.write` doesn't apply to a plug-in of kind `importer`"), + (Kind::Importer, false, &[], "needs the permission `import.read`"), + (Kind::Exporter, false, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"), ] { let e = validate(kind, commands, &list(names)).unwrap_err().to_string(); assert!(e.contains(says), "{names:?}: {e}"); diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index d2e0f2302..f9a566c81 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -220,3 +220,27 @@ pub fn load_folder_where( } Ok(report) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A cache keyed by the revision can't store the old list under the new revision: an install + /// waiting for the set leaves the revision alone until it holds the set. + #[test] + fn an_install_changes_the_revision_only_while_it_holds_the_set() { + let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap(); + let id = plugin.id().to_string(); + let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner()); + let before = revision(); + let installing = std::thread::spawn(move || install(plugin)); + let moved = (0..1_000_000).any(|_| { + std::thread::yield_now(); + revision() != before + }); + drop(held); + installing.join().unwrap(); + assert!(!moved, "the revision changed while the install waited for the set"); + assert!(revision() > before && remove(&id)); + } +} diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index d37a9159d..7eba48485 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -61,6 +61,10 @@ pub struct Limits { /// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more. pub heavy_fuel: u64, pub heavy_wall_time_ms: u64, + /// What one export or import is given instead of `fuel_base` and `wall_time_ms` + /// ([`crate::export::limits`]). + pub export_fuel: u64, + pub export_wall_time_ms: u64, } impl Default for Limits { @@ -75,6 +79,8 @@ impl Default for Limits { wall_time_ms: 60_000, heavy_fuel: HEAVY_FUEL, heavy_wall_time_ms: HEAVY_WALL_TIME_MS, + export_fuel: crate::export::FUEL, + export_wall_time_ms: crate::export::WALL_TIME_MS, } } } diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index f6bd289a6..0beabd34c 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -167,6 +167,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/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index bb32906ea..abc2cd05e 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -273,7 +273,46 @@ fn an_exporter_returns_its_file_with_an_export_s_budget() { // Its parameters are checked, and a filter is not an exporter. assert!(matches!(export::run(&p, b"{}", &json!({"level": "high"}), target), Err(Error::Params(_)))); let filter = Plugin::load(&ok_module("(i64.const 0)"), Limits::default()).unwrap(); - assert!(matches!(export::run(&filter, b"{}", &Value::Null, target), Err(Error::Params(_)))); + let e = export::run(&filter, b"{}", &Value::Null, target).unwrap_err(); + assert!(matches!(e, Error::Params(_)) && e.to_string().contains("is a plug-in of kind `filter`, not an exporter"), "{e}"); + let importer = Plugin::load(&build(&tpl::returning(&tpl::importer("test.reads", r#"["tst"]"#, "{}"), "{}")), Limits::default()).unwrap(); + let e = export::run(&importer, b"{}", &Value::Null, target).unwrap_err(); + assert!(e.to_string().contains("is a plug-in of kind `importer`, not an exporter"), "{e}"); +} + +/// An importer runs on an import's budget, which replaces its own instruction and time budgets and +/// keeps its other limits: a run that needs more than `fuel_base` finishes within `export_fuel`, +/// and a run that needs more than `export_fuel` ends there, however large `fuel_base` is. +#[test] +fn an_importer_runs_on_an_import_s_budget() { + use vectorcraft_plugins::export; + use vectorcraft_plugins::import::{self, Source}; + let run = format!( + "(local $n i32) (local.set $n (i32.const 100000)) \ + (loop $work (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}", + tpl::ECHO + ); + let wasm = build(&tpl::module(&tpl::importer("test.import.budget", r#"["tst"]"#, "{}"), &run, "", 1)); + let file = br#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1,0]}]}]}}]}"#; + let source = Source { extension: "tst", name: "a" }; + + let limits = import::limits(&Plugin::load(&wasm, Limits::default()).unwrap()); + assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS)); + let own = Limits { + fuel_base: 9_000, + fuel_per_byte: 0, + wall_time_ms: 8_000, + export_fuel: 10_000_000, + export_wall_time_ms: 7_000, + max_output_bytes: 6_000, + ..Limits::default() + }; + let p = Plugin::load(&wasm, own.clone()).unwrap(); + assert_eq!(import::limits(&p), Limits { fuel_base: 10_000_000, wall_time_ms: 7_000, ..own }); + assert_eq!(import::run(&p, file, &Value::Null, source).map(|i| i.objects.len()), Ok(1)); + + let p = Plugin::load(&wasm, Limits { fuel_base: 10_000_000, fuel_per_byte: 0, export_fuel: 9_000, ..Limits::default() }).unwrap(); + assert_eq!(import::run(&p, file, &Value::Null, source), Err(Error::Limit("instruction budget".into()))); } /// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 71b1aacdc..2596500af 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -443,7 +443,21 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))), } } - "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), + "app.open" => match p.get("importer").filter(|v| !v.is_null()) { + // The importer's parameters, else their defaults: automation gets no dialog asking for them. + Some(importer) => { + let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({})); + wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params}))) + } + // `params` go to the importer File › Open picks for the file, as in `document.open`. + None => { + let mut open = json!({"path": s("path")}); + if let Some(params) = p.get("params").filter(|v| !v.is_null()) { + open["params"] = params.clone(); + } + wrap(app.run("file.open", open)) + } + }, "app.save" | "app.export" => { let mut p = if p.is_object() { p.clone() } else { json!({}) }; // `wait: true`: the file is written before the reply, whatever Background Save and diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e5a47085f..786d5e56c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -400,6 +400,7 @@ registry! { PrintPresets: [print_presets::KIND] => print_presets::SPEC, Plugin: [plugin::KIND] => plugin::SPEC, PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC, + PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, @@ -825,6 +826,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/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 32ee696af..246aaf23f 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -28,7 +28,7 @@ const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")]; /// The formats Export As writes: the built-in ones, then those of the installed exporter plug-ins. pub(super) fn formats() -> Vec<&'static Format> { - BUILT_IN.iter().copied().chain(fileio::plugin_formats()).collect() + BUILT_IN.iter().copied().chain(fileio::plugin_formats().into_iter().filter(|f| f.write)).collect() } /// Is `f` an exporter plug-in's format? diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eebb29018..ba0075206 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -10,7 +10,9 @@ //! its own is a submenu named after it, holding its item and then its commands; a command with //! parameters opens the same dialog for them ([`open_command`]). 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; one with +//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it. use std::sync::Arc; @@ -76,6 +78,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, each with its commands if it has any (`None` /// while none is installed). Effects without options apply at once, as built-in ones do. pub fn effect_menu() -> Option { @@ -105,6 +119,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() { @@ -264,3 +279,34 @@ fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result app.ui.dialog = None; crate::io::export(app, format.as_deref(), path, &p) } + +/// The dialog kind of an importer's parameters, after Open With picked the file. +pub const IMPORT_KIND: &str = "pluginImport"; + +/// Headed by the plug-in's name, as it is; Open opens the file. +pub(super) const IMPORT_SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("__label"), + body: export_body, + confirm: import_confirm, + ok: Some("Open"), + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Open With with importer `plugin`, which has parameters: its fields at their defaults, and +/// `open`, Open With's own parameters (`path`, `importer`, and `picked` for a file from the +/// picker, which is read off the UI thread), kept for Open. +pub fn open_import(app: &mut VectorcraftApp, plugin: &Plugin, open: Value) { + let mut fields = fields_for(plugin.manifest()); + fields.insert("__open".into(), open); + app.ui.dialog = Some(Dialog { kind: IMPORT_KIND.into(), fields }); +} + +/// Opens the file with Open With's parameters and the plug-in's, as `params`. +fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = d.fields.get("__open").cloned().filter(Value::is_object).ok_or("no file to open")?; + p["params"] = form::params(d); + app.ui.dialog = None; + let picked = p["picked"] == true; + crate::io::open_with_on(app, &p, picked && crate::opening::available()) +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 5efa3ef6e..6f696f430 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -604,8 +604,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() { let r = crate::picks::in_dialog(app, d, |app, d| { - let pick = - crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() }; + let filters = crate::FilePick::filters(vectorcraft_engine::cmd::fileio::builtin_open_filters()); + let pick = crate::FilePick { filters, ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; let r = app.run("swatch.library.load", json!({ "path": path }))?; d.fields.insert("limitTo".into(), r["library"].clone()); 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..7dd035c1e --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -0,0 +1,292 @@ +//! 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::{self as tpl, Given}; + +use super::tests_export::{frame, kind, set}; +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"); + app.ui.recent_files.clear(); + 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); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&svg), "Open With adds the file to Open Recent"); + // `app.open {importer}` (MCP's open_file) opens as Open With does. + let (req, _rx) = crate::control::ControlRequest::new("app.open", json!({"path": svg, "importer": "plugin.test.ui.svgreader"})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") }; + assert_eq!(r["result"]["format"], "plugin.test.ui.svgreader", "{r}"); + assert_eq!(app.session.documents().len(), 3); + + // 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); +} + +/// Open With asks for an importer's parameters, at their defaults, once the file is picked, and +/// opens the file with the values set; given `params`, it asks nothing, and `app.open` without +/// them runs the importer on their defaults. +#[test] +fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { + let dir = tmp_dir("params"); + let mut app = app(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + // RECT when the parameters hold density 7; the file, which isn't JSON, otherwise. + install(&mut app, &tpl::when_given(&tpl::importer("test.ui.importparams", r#"["tup"]"#, params), Given::Params, r#""density":7.0"#, RECT)); + let file = dir.join("a.tup"); + std::fs::write(&file, "not json").unwrap(); + let file = file.to_string_lossy().into_owned(); + + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap(); + assert_eq!(kind(&app), Some(plugin::IMPORT_KIND)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.importparams".to_string(), Some(&json!(4.0)))); + assert!(app.session.documents().is_empty(), "nothing opened before Open"); + frame(&mut app); + set(&mut app, "density", json!(7)); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + + // Only a file from the picker goes off the UI thread after the dialog: a caller naming `path` + // gets the document. + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], false); + app.ui.dialog = None; + let picked = file.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], true); + app.ui.dialog = None; + + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file, "params": {"density": 7}})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.importparams"); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 2); + + // `app.open` (MCP's open_file) passes `params` on, and asks nothing without them. + let control = |app: &mut VectorcraftApp, p: serde_json::Value| { + let (req, _rx) = crate::control::ControlRequest::new("app.open", p); + let crate::control::Outcome::Done(r) = crate::control::handle(app, &egui::Context::default(), &req) else { panic!("not done") }; + r + }; + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams", "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}"); + assert_eq!(app.session.documents().len(), 3); + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams"})); + assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}"); + assert_ne!(kind(&app), Some(plugin::IMPORT_KIND)); + assert_eq!(app.session.documents().len(), 3); + + // Without `importer`, `params` go to the importer File › Open picks for the file. + let other = dir.join("b.tup"); + std::fs::write(&other, "not json").unwrap(); + let other = other.to_string_lossy().into_owned(); + let r = control(&mut app, json!({"path": other, "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}"); + assert_eq!(app.session.documents().len(), 4); + app.run("plugin.remove", json!({"id": "test.ui.importparams"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File › Open's dialog lists an importer's files; the open dialogs whose files only the built-in +/// readers read (New from Template, Load Styles, the library panels' Other Library…) don't. +#[test] +fn only_file_open_s_dialog_lists_an_importer_s_files() { + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.filters", r#"["tuf"]"#, "{}"), tpl::ECHO, "", 1)); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| { + seen.borrow_mut().push(pick.clone()); + None + })); + let lists = |filters: &[(String, Vec)]| filters.iter().any(|(_, exts)| exts.iter().any(|e| e == "tuf")); + let shown = |app: &mut VectorcraftApp, cmd: &str| { + let _ = app.run(cmd, json!({})); + let pick = asked.borrow_mut().pop().unwrap_or_else(|| panic!("{cmd} showed no open dialog")); + lists(&pick.filters) + }; + assert!(shown(&mut app, "file.open"), "File › Open"); + for cmd in ["file.newFromTemplate", "ui.loadCharStyles", "ui.loadParaStyles", "ui.loadAllStyles"] { + assert!(!shown(&mut app, cmd), "{cmd}"); + } + for kind in ["swatches", "graphicStyles", "symbols"] { + let filters = crate::FilePick::filters(crate::panels::library_panel::library_pick_filters(kind)); + assert!(!lists(&filters), "{kind}"); + } + app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap(); +} + +/// Export As lists an installed exporter and not an installed importer, whose format can only be +/// read, and it does not preset the importer's format. +#[test] +fn export_as_lists_exporters_and_not_importers() { + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.readonly", r#"["tur"]"#, "{}"), tpl::ECHO, "", 1)); + install(&mut app, &tpl::module(&tpl::exporter("test.ui.writes", r#"["tuw"]"#, "{}"), tpl::ECHO, "", 1)); + let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect(); + assert!(listed.contains(&"plugin.test.ui.writes"), "{listed:?}"); + assert!(!listed.contains(&"plugin.test.ui.readonly"), "{listed:?}"); + app.run("file.exportAs", json!({"format": "plugin.test.ui.readonly"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "png"); + app.run("plugin.remove", json!({"id": "test.ui.readonly"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.writes"})).unwrap(); +} + +/// A file only an importer reads opens wherever it is dropped, also when an exporter lists its +/// extension; a file File › Place reads is still placed on the canvas. +#[test] +fn a_dropped_file_only_an_importer_reads_opens() { + use crate::place::DropTarget; + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))); + let on_canvas = Some(egui::pos2(400.0, 300.0)); + let placed = |app: &VectorcraftApp, name, pos| matches!(app.drop_target(name, pos, false), DropTarget::Place(_)); + assert!(placed(&app, "a.tud", on_canvas), "no importer lists the extension yet"); + + install(&mut app, &tpl::module(&tpl::importer("test.ui.dropped", r#"["tud","tus","png"]"#, "{}"), tpl::ECHO, "", 1)); + install(&mut app, &tpl::module(&tpl::exporter("test.ui.dropshare", r#"["tus"]"#, "{}"), tpl::ECHO, "", 1)); + for name in ["a.tud", "A.TUD", "a.tus"] { + assert_eq!(app.drop_target(name, on_canvas, false), DropTarget::Open, "{name} on the canvas"); + assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name} without a position"); + } + assert!(placed(&app, "a.png", on_canvas), "File › Place reads the extension"); + assert!(placed(&app, "a.png", None)); + + app.run("plugin.remove", json!({"id": "test.ui.dropped"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.dropshare"})).unwrap(); +} + +/// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the +/// ones File › Open installs, imports, checks or loads as something other than a document. +#[test] +fn importer_filters_leave_out_what_file_open_opens_as_something_else() { + use vectorcraft_engine::cmd::{self, fileio}; + let lists: [&[&str]; 11] = [ + cmd::plugin::EXTS, + cmd::shortcutset::EXTS, + cmd::scripts::EXTS, + cmd::flatten::PRESET_EXTS, + cmd::pdfcmds::PRESET_EXTS, + cmd::printpresets::PRESET_EXTS, + cmd::perspgrid::PRESET_EXTS, + fileio::SCREEN_EXPORT_PRESET_EXTS, + cmd::compositefonts::EXTS, + cmd::library::LIBRARY_EXTS, + cmd::swatchlib::LIBRARY_EXTS, + ]; + for ext in lists.iter().flat_map(|l| l.iter().copied()).chain([vectorcraft_doc::style_libs::STYLES_EXT]) { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_some(), "{ext}"); + assert!(fileio::opens_as_other(ext), "{ext}"); + } + for ext in ["svg", "tcl", "pdf"] { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_none() && !fileio::opens_as_other(ext), "{ext}"); + } + // File › Open's dialog lists an importer's `.tuo` but not its `.ase` (a swatch library there). + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.claimed", r#"["tuo","ase"]"#, "{}"), tpl::ECHO, "", 1)); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| { + *seen.borrow_mut() = Some(pick.clone()); + None + })); + let _ = app.run("file.open", json!({})); + let pick = asked.borrow_mut().take().expect("File › Open showed its dialog"); + let own = pick.filters.iter().find(|(label, _)| label == "Test test.ui.claimed").map(|(_, exts)| exts.clone()); + assert_eq!(own, Some(vec!["tuo".to_string()]), "{:?}", pick.filters); + assert!(!pick.filters[0].1.iter().any(|e| e == "ase"), "All readable files: {:?}", pick.filters[0]); + app.run("plugin.remove", json!({"id": "test.ui.claimed"})).unwrap(); +} + +/// 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 81ac5abef..1a8c4c353 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6392,6 +6392,11 @@ breakText Glyphs Глифи 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 Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях + # Safe Mode Safe Mode… Безопасен режим… Safe Mode Безопасен режим diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ed69b0c2b..b7ed1bbbe 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6442,6 +6442,11 @@ shape Open Otvoren 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 + # Safe Mode Safe Mode… Sigurni način rada… Safe Mode Sigurni način rada diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b1723ddba..ccaf8d995 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6570,6 +6570,24 @@ breakText Glyphs Glifs 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 plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador +@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` + # Safe Mode Safe Mode… Mode segur… Safe Mode Mode segur diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 67aff0009..ce5bcd370 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6588,6 +6588,24 @@ breakText Glyphs Glyfy 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 plug-in of kind `{_2}`, not an importer `{_1}` je zásuvný modul druhu `{_2}`, ne importní zásuvný modul +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` je zásuvný modul druhu `{_2}`, ne exportní 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` + # Safe Mode Safe Mode… Nouzový režim… Safe Mode Nouzový režim diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3c0fae2df..429738169 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6406,6 +6406,11 @@ breakText Glyphs Glyffer 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 + # Safe Mode Safe Mode… Sikker tilstand… Safe Mode Sikker tilstand diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index da0979a6f..83a721a0f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6174,6 +6174,24 @@ breakText Glyphs Glyphen 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 plug-in of kind `{_2}`, not an importer `{_1}` ist ein Plug-in der Art `{_2}`, kein Import-Plug-in +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` ist ein Plug-in der Art `{_2}`, kein Export-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 + # Safe Mode Safe Mode… Abgesicherter Modus… Safe Mode Abgesicherter Modus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8c5a93c1..905694778 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6540,6 +6540,24 @@ breakText Glyphs Γλύφοι 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 plug-in of kind `{_2}`, not an importer το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εισαγωγής +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter το `{_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` + # Safe Mode Safe Mode… Ασφαλής λειτουργία… Safe Mode Ασφαλής λειτουργία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 175bbce3f..44c6aa8a5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6530,6 +6530,24 @@ breakText Glyphs Glifos 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 plug-in of kind `{_2}`, not an importer `{_1}` es un plugin de tipo `{_2}`, no un plugin de importación +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` es un plugin de tipo `{_2}`, no un plugin de exportació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` + # Safe Mode Safe Mode… Modo seguro… Safe Mode Modo seguro diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d62b07fc2..14f7e8d6b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6623,6 +6623,24 @@ breakText Glyphs Merkit 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 plug-in of kind `{_2}`, not an importer `{_1}` on lajin `{_2}` laajennus, ei tuontilaajennus +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` on lajin `{_2}` laajennus, ei vientilaajennus +@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 + # Safe Mode Safe Mode… Vikasietotila… Safe Mode Vikasietotila diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5b5217ec3..c2e59da2a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6583,6 +6583,24 @@ breakText Glyphs Glyphes 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 plug-in of kind `{_2}`, not an importer `{_1}` est un module externe de type `{_2}`, pas un module externe d’importation +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` est un module externe de type `{_2}`, pas un module externe d’exportation +@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` + # Safe Mode Safe Mode… Mode sans échec… Safe Mode Mode sans échec diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a2c443a21..a9c7f4f0d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5427,7 +5427,7 @@ antiAlias Sharp शार्प @msg `{_1}` can't be edited or removed `{_1}` को संपादित या हटाया नहीं जा सकता है @msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है @msg `{_1}` has no symbols to place `{_1}` के पास कोई सिंबल नहीं है -@msg `{_1}` is a {_2}, not an exporter `{_1}` एक {_2} है, एक निर्यातक नहीं है +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` `{_2}` प्रकार का प्लग-इन है, निर्यातक नहीं @msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` एक लाइव इफ़ेक्ट है: इसे effect.apply {{"effect"}"}} के साथ लागू करें @msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` एक एक्सपोर्टर है: document.export {{"format"}"}} से एक्सपोर्ट करें @msg `{_1}` is built in and always listed `{_1}` बनाया गया है और हमेशा सूचीबद्ध है @@ -6992,7 +6992,7 @@ antiAlias Sharp शार्प @msg Offset Path: the path is too complex to offset ऑफसेट पाथ: पाथ ऑफसेट करने के लिए बहुत जटिल है @msg on must be true or false on को true या false होना चाहिए @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}" @@ -8058,3 +8058,19 @@ theme Appearance अपीयरेंस Touching स्पर्श करते हुए Fully Enclosed पूरी तरह घिरे हुए Drag Direction खींचने की दिशा + +# 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 plug-in of kind `{_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 21e973a3f..70f2c779d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6572,6 +6572,24 @@ breakText Glyphs Glifák 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 plug-in of kind `{_2}`, not an importer `{_1}`: `{_2}` fajtájú bővítmény, nem importáló bővítmény +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`: `{_2}` fajtájú bővítmény, nem exportá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 + # Safe Mode Safe Mode… Csökkentett mód… Safe Mode Csökkentett mód diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3c8a1cd51..054938af1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6406,6 +6406,11 @@ breakText Glyphs Glif 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 + # Safe Mode Safe Mode… Mode aman… Safe Mode Mode aman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e81a396ac..0b34752a7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6208,6 +6208,24 @@ breakText Glyphs Glifi 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 plug-in of kind `{_2}`, not an importer `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di importazione +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di esportazione +@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` + # Safe Mode Safe Mode… Modalità provvisoria… Safe Mode Modalità provvisoria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c32890b47..72352dc1f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6273,6 +6273,24 @@ breakText Glyphs 字形 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 plug-in of kind `{_2}`, not an importer `{_1}` は種類 `{_2}` のプラグインで、読み込みプラグインではありません +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` は省略してください + # Safe Mode Safe Mode… セーフモード… Safe Mode セーフモード diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 458c3de2e..43787fc72 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6650,6 +6650,24 @@ font-script Japanese იაპონური 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 plug-in of kind `{_2}`, not an importer `{_1}` არის `{_2}` სახის პლაგინი და არა იმპორტის პლაგინი +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + # Safe Mode Safe Mode… უსაფრთხო რეჟიმი… Safe Mode უსაფრთხო რეჟიმი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 744a40d4f..ae35dfc68 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6169,6 +6169,24 @@ breakText Glyphs 글리프 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 plug-in of kind `{_2}`, not an importer `{_1}`은(는) `{_2}` 종류의 플러그인이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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`을 생략하세요 + # Safe Mode Safe Mode… 안전 모드… Safe Mode 안전 모드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 879ccf994..f9c6e6377 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6457,6 +6457,11 @@ breakText Glyphs Glyphs 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 + # Safe Mode Safe Mode… Veilige modus… Safe Mode Veilige modus diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6f4080b90..717646ed4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6443,6 +6443,24 @@ breakText Glyphs Glify 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 plug-in of kind `{_2}`, not an importer `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka importu +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka eksportu +@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` + # Safe Mode Safe Mode… Tryb awaryjny… Safe Mode Tryb awaryjny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7309efb0d..780f556e0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6661,6 +6661,24 @@ swatch Yellow Green Verde-amarelado 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 plug-in of kind `{_2}`, not an importer `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de importação +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de exportaçã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` + # Safe Mode Safe Mode… Modo de Segurança… Safe Mode Modo de Segurança diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 49ebed39f..addb45f58 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6677,6 +6677,24 @@ shape Open Открытый 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 plug-in of kind `{_2}`, not an importer `{_1}` является модулем вида `{_2}`, а не модулем импорта +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + # Safe Mode Safe Mode… Безопасный режим… Safe Mode Безопасный режим diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 047f25b27..01c52040d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6749,7 +6749,7 @@ digits Hindi Hindi @msg {what} points outside memory {what} pekar utanför minnet @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} skulle skrivas ut på fler än {MAX_TILES} rutor: skala ned det @msg `{_1}` can't be edited or removed `{_1}` kan inte redigeras eller tas bort -@msg `{_1}` is a {_2}, not an exporter `{_1}` är av typen {_2}, inte en exportfunktion +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` är ett plugin av typen `{_2}`, inte en exportfunktion @msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` är en live-effekt: använd den med effect.apply {{"effect"}"}} @msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` är en exportfunktion: exportera med document.export {{"format"}"}} @msg `{_1}` is built in and always listed `{_1}` är inbyggd och listas alltid @@ -7624,7 +7624,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) @@ -8075,6 +8075,22 @@ digits Hindi Hindi @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 plug-in of kind `{_2}`, not an importer `{_1}` är ett plugin 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` + # Safe Mode Safe Mode… Felsäkert läge… Safe Mode Felsäkert läge diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 547eb3f9d..11a579549 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6545,6 +6545,24 @@ breakText Glyphs Glifler 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 plug-in of kind `{_2}`, not an importer `{_1}`, `{_2}` türünde bir eklentidir, içe aktarma eklentisi değildir +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`, `{_2}` türünde bir eklentidir, dışa aktarma eklentisi değildir +@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 + # Safe Mode Safe Mode… Güvenli Mod… Safe Mode Güvenli Mod diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ace096ed5..2e354aded 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6188,6 +6188,24 @@ breakText Glyphs Гліфи 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 plug-in of kind `{_2}`, not an importer `{_1}` є модулем виду `{_2}`, а не модулем імпорту +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + # Safe Mode Safe Mode… Безпечний режим… Safe Mode Безпечний режим diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ef0f161b6..5e135a108 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6189,6 +6189,24 @@ breakText Glyphs Glyph 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 plug-in of kind `{_2}`, not an importer `{_1}` là plug-in loại `{_2}`, không phải plug-in nhập +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` là plug-in loại `{_2}`, không phải plug-in xuất +@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` + # Safe Mode Safe Mode… Chế độ an toàn… Safe Mode Chế độ an toàn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d837c451a..f69bfcfdb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6429,6 +6429,24 @@ breakText Glyphs 字形 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 plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 类型的增效工具,不是导入增效工具 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6a5ff47fc..c0116e8bc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6226,6 +6226,24 @@ breakText Glyphs 字符 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 plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 類型的增效模組,不是匯入增效模組 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1e34e38b..91f08da1b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -21,10 +21,10 @@ pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, e pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, }; -pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; +pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, 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_path_with, 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 a03118aa2..aa61d42a8 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -13,6 +13,11 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft" /// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for /// a document opened now (its `warnings` say what didn't come in as it was), else null. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { + open_bytes_with(app, name, bytes, path, &Value::Null) +} + +/// [`open_bytes`], a document opening with `document.open`'s params `p`. +fn open_bytes_with(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opens = opens_as(name); // A WebAssembly plug-in is installed. if opens == Some(OpensAs::Plugin) { @@ -53,7 +58,7 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti { return Ok(Value::Null); } - open_document(app, name, bytes, path, &Value::Null) + open_document(app, name, bytes, path, p) } /// The kinds of file File → Open installs, imports, checks, shows in the library panel or the @@ -175,6 +180,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"), Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), Some(permissions::Permission::Settings) => tl!("Keep settings of its own"), Some(permissions::Permission::Cache) => tl!("Keep a cache of its own, which you can clear"), @@ -276,6 +282,102 @@ 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, params}`), and adds it to Open Recent; a +/// failure is reported as File › Open reports it. An importer with parameters, given no `params`, +/// asks for them first in dialog `pluginImport`, at their defaults. 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. A file the user picked is read off the UI thread where that is available +/// ([`crate::opening`]), and the result is then `{background: true, status}`; with `path` the file +/// opens before the reply, as automation needs. +pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { + open_with_on(app, p, picks_a_file(p) && crate::opening::available()) +} + +/// Do File › Open With's parameters leave the file to the picker? +fn picks_a_file(p: &Value) -> bool { + p.get("path").and_then(Value::as_str).is_none_or(str::is_empty) +} + +/// [`open_with`]; with `background`, the importer runs off the UI thread +/// ([`crate::opening::start_with`]), its progress window offering Cancel. +pub(crate) fn open_with_on(app: &mut VectorcraftApp, p: &Value, background: bool) -> 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 importer = vectorcraft_plugins::export::format_id(plugin.id()); + let given = p.get("params").filter(|v| !v.is_null()); + let opened = vectorcraft_engine::file_access::check_read(&path).and_then(|()| check_import_size(&path, true)).and_then(|()| { + if given.is_none() && !plugin.manifest().params.is_empty() { + dialogs::plugin::open_import(app, &plugin, json!({ "path": path, "importer": importer, "picked": picks_a_file(p) })); + return Ok(Value::Null); + } + let mut o = json!({ "importer": importer }); + if let Some(params) = given { + o["params"] = params.clone(); + } + let mut bytes = read(app, &path)?; + if background { + match crate::opening::start_with(app, &path, bytes, o.clone()) { + Ok(r) => return Ok(r), + // No thread: at once. + Err(back) => bytes = back, + } + } + let r = open_document(app, &path, &bytes, Some(path.clone()), &o)?; + note_recent(app, &path); + Ok(r) + }); + 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) { @@ -339,9 +441,19 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result open_file(app, path, false) } +/// [`open_path`], with `params` for the importer plug-in that opens the file, if one does. +pub fn open_path_with(app: &mut VectorcraftApp, path: &str, params: &Value) -> Result { + open_file_with(app, path, false, &json!({ "params": params })) +} + /// [`open_path`]; with `background`, a document (not a file that installs, imports or asks /// first) is decoded off the UI thread ([`crate::opening::start`]). pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + open_file_with(app, path, background, &Value::Null) +} + +/// [`open_file`], a document opening with `document.open`'s params `p`. +fn open_file_with(app: &mut VectorcraftApp, path: &str, background: bool, p: &Value) -> Result { // Automation confined to some folders reaches no file outside them, open in a tab or not. vectorcraft_engine::file_access::check_read(path)?; // File › Open, Open Recent, the Home screen, a drop and the Finder all focus a document that @@ -373,6 +485,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. @@ -388,7 +502,7 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) Err(back) => bytes = back, } } - let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; + let r = open_bytes_with(app, path, &bytes, Some(path.to_string()), p)?; note_recent(app, path); Ok(r) } @@ -401,7 +515,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu Some(p) => p, None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), None => { - let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::builtin_open_filters())); pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? } }; diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 2b15eaba1..e26d0bdec 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -138,17 +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" | "svgFilter.apply") + || matches!(id, "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom" | "svgFilter.apply") || vectorcraft_engine::cmd::plugin::is_command_id(id) || (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) or one of its commands, by the plug-in's own text? 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) or one of its commands, by the plug-in's +/// own text? 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" + matches!(id, "plugin.dialog" | "file.openWith") || vectorcraft_engine::cmd::plugin::is_command_id(id) || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 5ce1ddbdf..ffc83c7af 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -107,9 +107,13 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match s("path") { - Some(path) => io::open_path(app, &path), + Some(path) => match p.get("params").filter(|v| !v.is_null()) { + Some(params) => io::open_path_with(app, &path, params), + None => 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 d8305d06a..da521390a 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 7fec7e206..81ae58673 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -28,7 +28,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.open", "Open…", "Cmd+O", - "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", + "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file", + ), + ( + "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) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open", ), ( "file.save", diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 9191509ae..99e0e9d6b 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use serde_json::{Value, json}; +use vectorcraft_engine::cancel::{self, CancelToken}; use vectorcraft_engine::cmd::fileio::{self, ReadFile}; use vectorcraft_engine::file_access::ScopeHandle; @@ -30,6 +31,8 @@ pub struct Pending { /// The automation roots in force where the open started: the document is installed under /// them too (its links are read as it opens). scope: ScopeHandle, + /// Raised by Cancel, on the thread reading the file: an importer plug-in stops. + token: CancelToken, /// Continue in Background: the progress window no longer shows it. pub hidden: bool, } @@ -65,6 +68,11 @@ pub fn available() -> bool { /// own ([`fileio::read_for_open`]) → `{background: true, status}`, or the bytes back when no thread /// could start (the caller opens the file at once). pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result> { + start_with(app, path, bytes, Value::Null) +} + +/// [`start`] with the `document.open` options in `params` (`importer` and its `params`). +pub fn start_with(app: &mut VectorcraftApp, path: &str, bytes: Vec, params: Value) -> Result> { let (tx, done) = channel(); let bytes = Arc::new(bytes); let (name, file) = (path.to_string(), bytes.clone()); @@ -74,11 +82,15 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result) -> Result Value { let cancelled = std::mem::take(&mut app.opening.pending); for p in &cancelled { + p.token.cancel(); // The status bar no longer says it is opening. if app.ui.status == format!("{}…", p.label) { app.ui.status.clear(); diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs index 1f5a6cbd1..ef62509bf 100644 --- a/crates/ui-egui/src/opening/tests.rs +++ b/crates/ui-egui/src/opening/tests.rs @@ -50,6 +50,74 @@ fn open_ready(app: &mut VectorcraftApp) { poll(app); } +fn install_importer(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 file for the importers below, in a folder of its own. +fn importer_file(tag: &str) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vc-ui-opening-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.tob"); + std::fs::write(&file, "not json").unwrap(); + let file = file.to_string_lossy().into_owned(); + (dir, file) +} + +/// Open With off the UI thread: nothing opens until the importer has read the file with the +/// parameters it was given; then the document opens and joins Open Recent, and a failure is +/// reported in a dialog. +#[test] +fn open_with_runs_the_importer_off_the_ui_thread() { + use vectorcraft_plugins::wat::{self as tpl, Given}; + let rect = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; + let (dir, file) = importer_file("with"); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + install_importer(&mut app, &tpl::when_given(&tpl::importer("test.opening.with", r#"["tob"]"#, params), Given::Params, r#""density":7"#, rect)); + + let open = |density: i64| json!({"importer": "plugin.test.opening.with", "path": file, "params": {"density": density}}); + let r = crate::io::open_with_on(&mut app, &open(7), true).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.session.documents().is_empty(), "nothing opens before the importer has read the file"); + assert!(app.ui.recent_files.is_empty()); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + + // Density 4 gives the file back, which isn't JSON. + app.ui.recent_files.clear(); + crate::io::open_with_on(&mut app, &open(4), true).unwrap(); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert!(app.ui.status.contains("Couldn't open a.tob"), "{}", app.ui.status); + assert!(app.ui.dialog.is_some(), "the failure is reported in a dialog"); + assert!(app.ui.recent_files.is_empty()); + app.run("plugin.remove", json!({"id": "test.opening.with"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel stops an importer that never returns, on the thread reading the file. +#[test] +fn cancel_stops_a_running_importer() { + use vectorcraft_plugins::wat as tpl; + let (dir, file) = importer_file("cancel"); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + install_importer(&mut app, &tpl::module(&tpl::importer("test.opening.spins", r#"["tob"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + crate::io::open_with_on(&mut app, &json!({"importer": "plugin.test.opening.spins", "path": file}), true).unwrap(); + let done = std::mem::replace(&mut app.opening.pending[0].done, std::sync::mpsc::channel().1); + assert_eq!(cancel_all(&mut app)["cancelled"], 1); + let r = done.recv().unwrap(); + assert!(r.as_ref().err().is_some_and(|e| e.contains("cancelled")), "{:?}", r.err()); + assert!(app.session.documents().is_empty()); + app.run("plugin.remove", json!({"id": "test.opening.spins"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1694: a file that finishes opening while a dialog is up waits for it to close, so the /// dialog's document stays the active one. #[test] diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 4607edbf9..f7ee6020a 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -573,7 +573,7 @@ pub(crate) fn library_pick_filters(kind: &str) -> Vec<(&'static str, &'static [& match kind { super::swatches::SwatchLibraries::KIND => fileio::library_open_filters("Swatch libraries", swatchlib::LIBRARY_EXTS).collect(), super::graphic_styles::GraphicStyleLibraries::KIND => fileio::library_open_filters("Graphic Style Libraries", &[STYLES_EXT]).collect(), - _ => fileio::open_filters().collect(), + _ => fileio::builtin_open_filters().collect(), } } diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 241d13ea2..cee836a38 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -451,7 +451,7 @@ pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option) - let path = match path { Some(path) => path, None => { - let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::open_filters()), ..Default::default() }; + let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::builtin_open_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(Value::Null) }; path } 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/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index d4f4412ec..ebb42852a 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -444,12 +444,12 @@ impl VectorcraftApp { /// onto the canvas of the open document (Shift embeds it), else opened. Where the platform /// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its /// own and a picture or text is placed in the middle of the view. A library, a presets file, a - /// plug-in, a shortcut set or a script opens wherever it is dropped, as File → Open opens it - /// ([`io::opens_as`]). + /// plug-in, a shortcut set, a script or a file only an importer plug-in reads opens wherever it + /// is dropped, as File → Open opens it ([`io::opens_as`], [`fileio::opens_with_importer`]). pub fn drop_target(&self, name: &str, pos: Option, shift: bool) -> DropTarget { let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open }; let at = match pos { - _ if io::opens_as(name).is_some() => return DropTarget::Open, + _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), None if is_document(name) => return DropTarget::Open, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0e6611a85..290619b2a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | @@ -213,7 +213,9 @@ after it), `__plugin` (the plug-in id) and `preview`. It previews `plugin.run` o `ui.dialog.confirm` keeps the result as one undo step and `ui.dialog.cancel` rolls it back. Live-effect plug-ins open the `effect` dialog (`effect.dialog {effect: "plugin."}`). An exporter plug-in with parameters opens the `pluginExport` dialog once Export As has picked the file: one field per parameter, `__plugin`, and `__export` (the -export's own parameters); `ui.dialog.confirm` writes the file. `ui.installPlugin {path?}` installs a `.wasm` (without +export's own parameters); `ui.dialog.confirm` writes the file. An importer plug-in with parameters opens the +`pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open` +(`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without a path it asks for one); see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for diff --git a/docs/gaps.md b/docs/gaps.md index 48897777f..ed9af9b86 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -748,9 +748,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 8bfd90b98..e4ce2bd54 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -462,9 +462,9 @@ 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`), HEIC/HEIF (`.heic`/`.heif`), 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, importer?, params?}` | 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`), HEIC/HEIF (`.heic`/`.heif`), 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)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `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`). `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 warning for a top-level `compress`, `version`, `preview` or `precision`. 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. | -| `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, importer?, params?}` | 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)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `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`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `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. | @@ -6613,8 +6613,21 @@ 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`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: +"plugin.", params?}` (and `open_file {path, importer?, params?}`) 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?, params?}` opens `path`, or a file picked among the importer's extensions, with one (on +the web it opens File › Open's picker) and adds it to Open Recent. With `path` the reply is `document.open`'s result; +on the desktop a picked file is read off the UI thread and the reply is `{background: true, status}`, and +`file.cancelOpen` stops the importer. An importer with parameters, given no `params`, first asks for them in the +`pluginImport` dialog. `params` holds the importer's parameters (their defaults when +omitted). 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`) @@ -6689,6 +6702,7 @@ Disable and Continue can't save the plug-in it turns off, this launch skips it a {"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"}}} {"name":"run_command","arguments":{"command":"plugin.org.example.tidy.grow","params":{"by":3}}} {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/tiles-2.wasm","replace":true}}} {"name":"run_command","arguments":{"command":"plugin.clearCache","params":{"id":"org.example.tiles"}}} diff --git a/docs/plugins.md b/docs/plugins.md index 97789dd41..adbd128cc 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`). @@ -39,6 +42,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. `openExtensions` +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `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, +and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens as a new document when it is dropped on the canvas. 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 @@ -48,7 +61,21 @@ it can be given a shortcut (`plugin:`); the shortcut stays saved while the p when it is reinstalled. 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 (other open dialogs, such as New +from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's +extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, +`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give +those files to the importer, and Open With does. **File › Open With** lists the +installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its +extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. On the +desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which +stops it. An importer +with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open +button opens the file; File › Open runs an importer with its parameters' defaults. Open Recent opens a file the way +File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the +file if one can, and otherwise the first importer by id that lists its extension reads it with its parameters' defaults. +**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)). @@ -99,20 +126,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. @@ -138,22 +167,24 @@ capability model proposed for PhotoCraft plug-ins | Permission | Kinds | What it allows | |---|---|---| -| `doc.read` | all | Read the objects it is given. | +| `doc.read` | filter, effect, exporter | Read the objects it is given. | | `doc.write` | filter, effect with `commands` | Change, add and delete objects in the document. | | `state` | filter, effect with `commands` | 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. | | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | | `settings` | all | Keep settings of its own between runs and sessions ([settings and cache](#settings-and-cache)). Needs `api` 2. | | `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | - 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`, and also `doc.write` and `state` - when it declares [commands](#commands); 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 `doc.read` and its own (`effect.render`, `export.write`). An - effect with commands may add `doc.write` and `state`, which only its commands use. A filter may list any of its three. + when it declares [commands](#commands); an exporter `doc.read` and `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 (`doc.read` and + `effect.render`; `doc.read` and `export.write`; `import.read`). An effect with commands may add `doc.write` and + `state`, which only its commands use. A filter may list any of its three. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or one of the names kept for a later plug-in API @@ -338,16 +369,17 @@ pass the 1 GiB returns `-7` before it reads or writes anything. - `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. - `commands` (filters and live effects; not heavy filters): up to 32 commands of the plug-in's own ([Commands](#commands)), each `{"id": "grow", "label": "Grow", "params"?: {…}}`. An `id` is 1–32 characters from `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with @@ -401,6 +433,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", @@ -445,15 +479,17 @@ 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"`, `"export"` or `"command"` (a [command](#commands) run, whose `command` is the -command's id); `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"`, `"import"` or `"command"` (a [command](#commands) run, whose +`command` is the command's id); `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 @@ -504,6 +540,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 @@ -532,8 +573,9 @@ 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, importers - (plug-in formats that open files). +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, 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 a1f50cc7c..8e53ad045 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -693,8 +693,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -1554,6 +1555,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Envelope names given as another type:** `object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` or `orientation` other than text or null, such as `5`, and an `orientation` other than `horizontal` or `vertical`; `object.envelope.options` refuses such a `preserveShape`. `object.envelope.options` checks the warp keys with no envelope or only mesh envelopes selected, too. Each leaves the document, the options new envelopes get and the 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 instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0324964c5..260b10387 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,8 +44,9 @@ commands: stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 verified), parse its ES5 subset with vectorcraft-js and run the ES5 language - tests its interpreter supports and the String, Number, RegExp and Date - built-ins tests, printing the counts + and built-in tests its interpreter supports and the String, Number, RegExp + and Date built-ins tests, printing the counts; then fetch json2.js into + corpus/json2 (sha256 verified) and run it web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -342,6 +343,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 verified: the JavaScript parser's and interpreter's ES5 subsets. Fetch and run: cargo xtask test262 + corpus/json2/ json2.js, the public-domain JSON polyfill, at a pinned commit, sha256 verified: + compared with vectorcraft-js's JSON. Fetch and run: cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index 88cbef395..dfed1d8ae 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -2,17 +2,34 @@ //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then //! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 -//! `test/language` tests that need only the interpreter's built-ins, run the `test/built-ins` tests -//! for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. +//! `test/language` tests and the tests of the ES5 built-ins under `test/built-ins` and +//! `test/annexB/built-ins` that need only the interpreter's built-ins, run the `test/built-ins` +//! tests for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. //! -//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the -//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. +//! It also fetches `json2.js`, Douglas Crockford's public-domain JSON polyfill +//! (https://github.com/douglascrockford/JSON-js), at a pinned commit, checks it against its SHA-256 +//! and size, saves it in `corpus/json2/` (gitignored, never committed), and runs `vectorcraft-js`'s +//! `json2` test, which runs the polyfill and compares what it writes and reads with the built-in +//! `JSON`. +//! +//! Moving the pin: change [`COMMIT`] (or [`JSON2_COMMIT`]), run the command once with the new +//! commit to see the file's SHA-256 in the error, check the file, and update [`SHA256`] (or +//! [`JSON2_SHA256`]). -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::{cargo, root, run, sha256}; +/// The douglascrockford/JSON-js commit `json2.js` is read at. +const JSON2_COMMIT: &str = "7e83f38a2312429fd4933169c1f6a27fd65e889c"; + +/// The SHA-256 of `json2.js` at [`JSON2_COMMIT`]. +const JSON2_SHA256: &str = "86df14b56572e68a7e10b18e71804cb78c6890a7d6e534324822c23d12e858a2"; + +/// The largest `json2.js` accepted (the pinned one is 18,876 bytes). +const JSON2_MAX_BYTES: u64 = 1 << 20; + /// The tc39/test262 commit the suite is read at. const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; @@ -26,11 +43,44 @@ pub fn run_suite() -> Result<(), String> { let dir = fetch()?; let mut c = cargo(); c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); - run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display()))?; + let json2 = fetch_json2()?; + let mut c = cargo(); + c.env("VECTORCRAFT_JSON2", &json2).args(["test", "-p", "vectorcraft-js", "--release", "--test", "json2", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test json2 -- --nocapture (VECTORCRAFT_JSON2={})", json2.display())) +} + +/// Fetches `json2.js` unless a copy with the pinned SHA-256 is there already, and returns its path. +fn fetch_json2() -> Result { + let base = root().join("corpus").join("json2").join(JSON2_COMMIT); + let file = base.join("json2.js"); + let matches = |p: &Path| -> Result { + let Ok(meta) = std::fs::metadata(p) else { return Ok(false) }; + if meta.len() > JSON2_MAX_BYTES { + return Err(format!("{} is {} bytes, over the {JSON2_MAX_BYTES}-byte limit", p.display(), meta.len())); + } + Ok(std::fs::read(p).is_ok_and(|b| sha256::hex(&b) == JSON2_SHA256)) + }; + if matches(&file)? { + println!("json2.js {JSON2_COMMIT} is in {}", file.display()); + return Ok(file); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let url = format!("https://raw.githubusercontent.com/douglascrockford/JSON-js/{JSON2_COMMIT}/json2.js"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &JSON2_MAX_BYTES.to_string(), "-o"]).arg(&file).arg(&url); + run(curl, &format!("curl {url}"))?; + if !matches(&file)? { + let got = std::fs::read(&file).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&file); + return Err(format!("json2.js: sha256 {got} does not match the pinned {JSON2_SHA256}")); + } + println!("json2.js {JSON2_COMMIT} saved in {}", file.display()); + Ok(file) } /// Fetches and unpacks the suite unless it is there already, and returns its folder. -fn fetch() -> Result { +fn fetch() -> Result { let base = root().join("corpus").join("test262"); let dir = base.join(format!("test262-{COMMIT}")); let marker = dir.join(".vectorcraft-sha256");