mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
Merge main into fix/issue-3526-docs
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -728,12 +728,12 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Ve
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_openable(path: &str) -> 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)]
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 <image> 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.<id>\" (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 <image> 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<CommandSpec> {
|
||||
"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.<plug-in id>; 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.<plug-in id>; 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
|
||||
),
|
||||
|
||||
@@ -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<Item = (&'static str, &'static [&'static st
|
||||
FORMATS.iter().filter(|f| f.read).map(|f| (f.label, read_extensions(f)))
|
||||
}
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format, 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).
|
||||
/// Whether a file named `name` can only be read by an importer plug-in: an installed importer lists
|
||||
/// its extension and File › Place does not read that extension.
|
||||
pub fn opens_with_importer(name: &str) -> 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<Option<(u64, ImporterPart)>> = 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<Item = (&'static str, &'static [&'static str])> {
|
||||
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<Item = (&'static str, &'static [&'static str])> {
|
||||
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<Item = (&'static str, &'static [&'static str])> {
|
||||
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<Item = (&'static str, &'static [&'static
|
||||
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
|
||||
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
|
||||
/// match the default filter) still list `.ase` and the other library extensions; then the same
|
||||
/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles.
|
||||
/// filters as [`builtin_open_filters`] so a document can be picked for its swatches or graphic
|
||||
/// styles.
|
||||
pub fn library_open_filters(
|
||||
library_label: &'static str,
|
||||
library_exts: &'static [&'static str],
|
||||
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
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<Item = (&'static str, &'static [&'static
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
|
||||
/// one, else one an exporter plug-in adds ([`plugin_formats`]). A built-in format keeps its
|
||||
/// extension, so an exporter that lists it too is found by its format id.
|
||||
/// one, else one a plug-in adds ([`plugin_formats`]), an exporter's before an importer's for an
|
||||
/// extension both list. A built-in format keeps its extension, so an exporter or importer that
|
||||
/// lists it too is found by its format id.
|
||||
pub fn format(id_or_ext: &str) -> 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<Vec<(u64, &'static Format)>> = 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<Value> {
|
||||
"formats": all().map(listed).collect::<Vec<_>>(),
|
||||
"readable": ids(|f| f.read),
|
||||
"writable": ids(|f| f.write),
|
||||
"openExtensions": OPEN_EXTS,
|
||||
"openExtensions": open_extensions(),
|
||||
"unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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.<id>`), 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<Option<&str>> {
|
||||
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<Option<Arc<Plugin>>> {
|
||||
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<String> = 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<u8> = 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Loaded> {
|
||||
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)
|
||||
}
|
||||
@@ -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<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
|
||||
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<Source<'a>> {
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
|
||||
@@ -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<u8>| 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);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! `stroke.set` whatever is selected), a graphic style clicked with nothing selected (more fills
|
||||
//! and strokes, effects, opacity and blend mode, and the link to the style) or, with the Appearance
|
||||
//! panel's New Art Has Basic Appearance off, the whole appearance of the last selection. With the
|
||||
//! option on (the default) an inherited appearance gives new art its top stroke's options only.
|
||||
//! `paint.default` resets it.
|
||||
//! option on (the default) an inherited appearance gives new art its top stroke's options only,
|
||||
//! and a change of selection loads only the proxies. `paint.default` resets it.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Art Has Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options); off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}",
|
||||
"{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options), the fill, stroke and weight the selection shares when it last changed; off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}",
|
||||
always,
|
||||
set_basic
|
||||
),
|
||||
@@ -213,6 +213,34 @@ impl Session {
|
||||
let (ap, opacity, blend) = (super::style::captured(n), n.opacity, n.blend);
|
||||
self.take_new_art(ap, opacity, blend, style, true);
|
||||
}
|
||||
|
||||
/// After a command changed the selection, with New Art Has Basic Appearance on: the fill and
|
||||
/// stroke proxies and the weight take what the selection shows in them, so new art looks like
|
||||
/// it. A paint the selected objects don't share (a "?" proxy) stays as it was, and art without
|
||||
/// paint (a placed image, whose None would make new art invisible) changes nothing. The
|
||||
/// template is left alone, and without a stroke to show the weight stays too.
|
||||
pub(crate) fn load_selection_paints(&mut self) {
|
||||
if !self.prefs.new_art_basic {
|
||||
return;
|
||||
}
|
||||
let has_paint = self.proxy_source(false).is_some_and(|(n, _)| matches!(n.kind, NodeKind::Text(_)) || !n.appearance.items.is_empty());
|
||||
if !has_paint {
|
||||
return;
|
||||
}
|
||||
let (fill_mixed, stroke_mixed) = self.proxy_mixed();
|
||||
let (fill, stroke) = self.proxy_paints();
|
||||
let weight_mixed = self.active().is_some_and(|st| st.stroke_mixed().weight);
|
||||
let weight = self.shown_stroke().filter(|st| st.paint != Paint::None && !weight_mixed).map(|st| st.width);
|
||||
if !fill_mixed {
|
||||
self.paint.fill = unplaced(&fill);
|
||||
}
|
||||
if !stroke_mixed {
|
||||
self.paint.stroke = unplaced(&stroke);
|
||||
}
|
||||
if let Some(width) = weight {
|
||||
self.paint.stroke_width = width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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.<id>"}`) 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<Value>
|
||||
/// 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<Vec<Effect>, 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<Vec<Effect>, 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<Vec<Effect>, 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)
|
||||
@@ -859,7 +871,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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.<id>), format? and extensions? (an exporter's format id, plugin.<id>, 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.<id>.<command id>, 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.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, 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.<id>.<command id>, 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
|
||||
),
|
||||
|
||||
@@ -226,6 +226,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("leading_model", json!(v.leading_model)),
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
("kinsoku_type", json!(v.kinsoku_type)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
("kashida", json!(v.kashida)),
|
||||
] {
|
||||
|
||||
@@ -98,6 +98,43 @@ fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() {
|
||||
assert_eq!(old.kinsoku, Kinsoku::Hard);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Kinsoku Shori Type: `text.setFormat {kinsokuType}` sets it per
|
||||
/// paragraph in one undo step, refuses unknown values, saves it only when it is not Push Out Only
|
||||
/// (new type's and older documents' type), reopens with the file, and paragraph styles carry it.
|
||||
#[test]
|
||||
fn kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only() {
|
||||
use vectorcraft_doc::KinsokuShoriType;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あ」\nい", "area": {"width": 100, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushOutOnly);
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kinsoku_type").is_none());
|
||||
s.execute("text.setFormat", &json!({"id": id, "start": 0, "end": 1, "kinsokuType": "pushInFirst"})).unwrap();
|
||||
assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kinsoku_type"], "pushInFirst");
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).kinsoku_type, back.para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly));
|
||||
assert!(s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushIn"})).is_err());
|
||||
assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushInFirst, "an unknown value changes nothing");
|
||||
s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushOutFirst"})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly));
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Pushed in", "attrs": {"kinsoku_type": "pushOutFirst"}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Pushed in", "id": id})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst);
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert_eq!(old.kinsoku_type, KinsokuShoriType::PushOutOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burasagari_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
use vectorcraft_doc::Burasagari;
|
||||
|
||||
@@ -19,12 +19,20 @@ pub struct HistoryEntry {
|
||||
next: usize,
|
||||
/// The template and name `label` was made from ([`Session::edit_named`]).
|
||||
pub(crate) named: Option<NamedStep>,
|
||||
/// A number no other step takes ([`History::step`]).
|
||||
serial: u64,
|
||||
/// Original and converted new-art colors, only for steps that change document color mode.
|
||||
pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>,
|
||||
/// Owned new-art snapshots included in both directional estimates.
|
||||
color_mode_bytes: usize,
|
||||
}
|
||||
|
||||
/// The next [`HistoryEntry::serial`]: 1 for the first step made, so 0 never names a step.
|
||||
fn next_serial() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A step name made by filling the `{name}` placeholder of a template with a name
|
||||
/// ([`Session::edit_named`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -57,6 +65,7 @@ impl HistoryEntry {
|
||||
next_bytes,
|
||||
next: std::ptr::from_ref(next) as usize,
|
||||
named: None,
|
||||
serial: next_serial(),
|
||||
color_mode_paints: None,
|
||||
color_mode_bytes: 0,
|
||||
}
|
||||
@@ -88,6 +97,7 @@ impl HistoryEntry {
|
||||
next_bytes: costs.bytes,
|
||||
next: Arc::as_ptr(now) as usize,
|
||||
named: None,
|
||||
serial: next_serial(),
|
||||
color_mode_paints: None,
|
||||
color_mode_bytes: 0,
|
||||
}
|
||||
@@ -155,6 +165,13 @@ impl History {
|
||||
self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum()
|
||||
}
|
||||
|
||||
/// Which step the document is at: the newest undo step and the next redo step, by numbers no
|
||||
/// other step takes (0 for none). Every new step, Undo, Redo and edit merged into the newest
|
||||
/// step (an undo group) changes it; a selection change does not.
|
||||
pub fn step(&self) -> (u64, u64) {
|
||||
(self.undo.last().map_or(0, |e| e.serial), self.redo.last().map_or(0, |e| e.serial))
|
||||
}
|
||||
|
||||
/// The document at row `row` of the History panel, `now` being the document now: the rows before
|
||||
/// it are the undo steps, oldest first, and the rows after it the redo steps, nearest first.
|
||||
pub(crate) fn state<'a>(&'a self, now: &'a Arc<Document>, row: usize) -> Option<&'a Arc<Document>> {
|
||||
@@ -192,13 +209,14 @@ impl History {
|
||||
}
|
||||
|
||||
/// Estimate the newest undo step again, its next state now being `now` (the steps after it were
|
||||
/// merged into it, or an undo group extended it).
|
||||
/// merged into it, or an undo group extended it). It takes a new number ([`History::step`]).
|
||||
pub(crate) fn recost_last(&mut self, now: &Document) {
|
||||
if let Some(e) = self.undo.last_mut() {
|
||||
let (before, after) = e.doc.retained_bytes_each(now);
|
||||
e.bytes = before.saturating_add(e.color_mode_bytes);
|
||||
e.next_bytes = after.saturating_add(e.color_mode_bytes);
|
||||
e.next = std::ptr::from_ref(now) as usize;
|
||||
e.serial = next_serial();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ pub struct Session {
|
||||
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
|
||||
/// back as it was at the release ([`Session::pointer`]).
|
||||
pub(crate) lender: Option<Box<dyn Tool>>,
|
||||
/// The active document (its uid) and its [`History::step`] when the tool last handled a pointer
|
||||
/// event or drew its overlays: when they differ at the next one, the tool is told first
|
||||
/// ([`vectorcraft_tools::Tool::history_moved`]). None until the first.
|
||||
pub(crate) tool_step: Option<(u64, (u64, u64))>,
|
||||
/// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's
|
||||
/// gesture found, so that one that leaves it as it was picks nothing.
|
||||
pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>,
|
||||
|
||||
@@ -46,6 +46,7 @@ impl Session {
|
||||
journal: Journal::default(),
|
||||
tool: vectorcraft_tools::create("selection"),
|
||||
lender: None,
|
||||
tool_step: None,
|
||||
selection_at_lend: None,
|
||||
lent_selection: None,
|
||||
last_selection_tool: None,
|
||||
|
||||
@@ -113,6 +113,9 @@ impl Session {
|
||||
})
|
||||
.flatten();
|
||||
let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer));
|
||||
// Only a selection command that changes the selection loads its paints for new art: other
|
||||
// commands, and clicking what is already selected, keep a sampled paint in the proxies.
|
||||
let selection_was = (id.starts_with("select.") || id == "layer.target").then(|| self.active().map(|d| d.selection.clone())).flatten();
|
||||
let run = |s: &mut Session| {
|
||||
if let Some(ids) = &select_first {
|
||||
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
|
||||
@@ -150,6 +153,9 @@ impl Session {
|
||||
});
|
||||
if !restored {
|
||||
self.inherit_new_art();
|
||||
if selection_was.is_some_and(|was| self.active().is_some_and(|d| d.selection != was)) {
|
||||
self.load_selection_paints();
|
||||
}
|
||||
}
|
||||
for uid in std::mem::take(&mut self.batch_paint_docs) {
|
||||
if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
|
||||
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and
|
||||
//! Escape dropping the first pick.
|
||||
//! Escape, or an edit made outside the tool, dropping the first pick.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
||||
@@ -233,6 +233,102 @@ fn escape_drops_the_blend_tools_first_pick() {
|
||||
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() {
|
||||
for case in ["edit", "undo", "redo", "group"] {
|
||||
let undo = case == "undo";
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
if case == "redo" {
|
||||
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// In an open undo group, the edit after the pick is merged into the group's step.
|
||||
if case == "group" {
|
||||
s.begin_undo_group();
|
||||
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap();
|
||||
}
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
// Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked.
|
||||
click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0);
|
||||
assert!(!s.overlays(ViewInfo::default()).is_empty());
|
||||
match case {
|
||||
"undo" => s.execute("edit.undo", &json!({})).unwrap(),
|
||||
"redo" => s.execute("edit.redo", &json!({})).unwrap(),
|
||||
_ => s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(),
|
||||
};
|
||||
assert!(s.overlays(ViewInfo::default()).is_empty(), "{case}: the pick is not marked");
|
||||
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
|
||||
click(&mut s, 220.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "{case}: b is picked first");
|
||||
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "{case}: the click changes no art");
|
||||
click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_picks_with_no_edit_between_them_blend() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
// Drawing the overlays between the picks does not drop the first one.
|
||||
assert!(!s.overlays(ViewInfo::default()).is_empty());
|
||||
click(&mut s, 220.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![a, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_blend_the_blend_tool_adds_to_ends_adding() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
let (d, e) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0));
|
||||
let other = id_of(&s.execute("object.blend.make", &json!({"ids": [d.0, e.0]})).unwrap());
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
click(&mut s, 220.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
s.execute("edit.clear", &json!({"ids": [g.0]})).unwrap();
|
||||
sel(&mut s, &[other]);
|
||||
click(&mut s, 420.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![c], "c is picked first");
|
||||
assert_eq!(spec(&s, other).0, vec![d, e], "c is not added to the selected blend");
|
||||
assert!([a, b, g].iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()), "the blend went with its keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_change_keeps_the_blend_tools_first_pick() {
|
||||
for drag in [false, true] {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
let view = ViewInfo::default();
|
||||
s.select_tool("blend", view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), view).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
// Cmd lends the selection tool: a click on c selects it, a drag moves it (an edit).
|
||||
let cmd = Mods { cmd: true, ..Mods::default() };
|
||||
let to = if drag { 440.0 } else { 420.0 };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 20.0).with_mods(cmd), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to, 20.0).with_mods(cmd), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to, 20.0).with_mods(cmd), view).unwrap();
|
||||
assert_eq!(s.tool_id(), "blend");
|
||||
assert_eq!(s.overlays(view).is_empty(), drag, "drag {drag}");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 220.0, 20.0), view).unwrap();
|
||||
if drag {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "the drag dropped the pick");
|
||||
} else {
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![a, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_points_round_trip_and_old_blends_load() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -180,6 +180,47 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
|
||||
assert_eq!(first_x(&s), start);
|
||||
}
|
||||
|
||||
/// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit
|
||||
/// leave as many steps as before, and with an edit merged into an undo group's step. It stays the
|
||||
/// same for a selection change.
|
||||
#[test]
|
||||
fn the_history_step_changes_with_each_step_undo_and_redo() {
|
||||
let mut s = session_with_rects(2);
|
||||
let step = |s: &Session| s.doc().unwrap().history.step();
|
||||
let start = step(&s);
|
||||
assert!(start.0 != 0 && start.1 == 0, "the Add step, and nothing to redo: {start:?}");
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
let moved = step(&s);
|
||||
assert!(moved.0 != start.0 && moved.1 == 0, "{moved:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let undone = step(&s);
|
||||
assert!(undone.0 == start.0 && undone.1 != 0, "{undone:?}");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let redone = step(&s);
|
||||
assert!(![start, moved, undone].contains(&redone) && redone.1 == 0, "{redone:?}");
|
||||
// An undo and a new edit: as many undo steps as after the move, at a different step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 1})).unwrap();
|
||||
let other = step(&s);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert!(![start, moved, undone, redone].contains(&other), "{other:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(step(&s), other, "a selection change is no step");
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.begin_undo_group();
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
let (grouped, depth) = (step(&s), s.doc().unwrap().history.undo.len());
|
||||
assert_ne!(grouped, other);
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "the second move is merged into the first");
|
||||
let merged = step(&s);
|
||||
assert!(merged != grouped && merged.1 == 0, "a merged edit moves the step: {merged:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(step(&s), merged);
|
||||
s.end_undo_group(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_mode_paint_snapshots_count_toward_the_history_budget() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -141,6 +141,136 @@ fn a_graphic_style_clicked_with_nothing_selected_styles_the_next_object() {
|
||||
assert!(s.execute("graphicStyle.apply", &json!({"name": name, "ids": []})).is_err(), "explicit ids that match nothing");
|
||||
}
|
||||
|
||||
/// A rectangle with this fill, stroke and weight, selected.
|
||||
fn painted(s: &mut Session, x: f64, fill: &str, stroke: &str, weight: f64) -> NodeId {
|
||||
let id = rect(s, x);
|
||||
run(s, "paint.setFill", json!({"color": fill}));
|
||||
run(s, "paint.setStroke", json!({"color": stroke}));
|
||||
run(s, "stroke.set", json!({"weight": weight}));
|
||||
id
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
run(s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_an_object_gives_new_art_its_fill_stroke_and_weight_with_basic_appearance_on() {
|
||||
let mut s = session();
|
||||
assert!(s.prefs.new_art_basic, "on by default");
|
||||
let green = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
|
||||
select(&mut s, &[green]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 220.0);
|
||||
let (n, from) = (node(&s, id).appearance, node(&s, green).appearance);
|
||||
assert_eq!((n.fill_paint(), n.stroke_paint(), n.stroke_width()), (from.fill_paint(), from.stroke_paint(), 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_basic_appearance_on_only_the_basic_fill_and_stroke_follow_the_selection() {
|
||||
let mut s = session();
|
||||
let src = fancy(&mut s);
|
||||
painted(&mut s, 120.0, "#00ff00", "#000000", 2.0);
|
||||
select(&mut s, &[src]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 220.0);
|
||||
let n = node(&s, id);
|
||||
assert_eq!(n.appearance.fill_paint(), node(&s, src).appearance.fill_paint(), "the top fill");
|
||||
assert_eq!((n.appearance.items.len(), n.appearance.effects.len(), n.opacity), (2, 0, 1.0), "one fill and stroke, nothing else");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paint_the_selected_objects_do_not_share_stays_as_it_was() {
|
||||
let mut s = session();
|
||||
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let b = painted(&mut s, 120.0, "#0000ff", "#00ff00", 5.0);
|
||||
painted(&mut s, 220.0, "#ffff00", "#000000", 1.0);
|
||||
let before = s.paint.fill.clone();
|
||||
select(&mut s, &[a, b]);
|
||||
assert_eq!(s.paint.fill, before, "the fills differ");
|
||||
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (node(&s, a).appearance.stroke_paint(), 5.0), "the strokes are shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_without_paint_leaves_the_proxies_as_they_were() {
|
||||
let mut s = session();
|
||||
painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone());
|
||||
let dir = super::links::Folder::new("new-art-image");
|
||||
let path = dir.file("pic.png");
|
||||
super::links::write(&path, &super::links::png(4, 4, super::links::RED));
|
||||
let image = super::links::place(&mut s, &path);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
select(&mut s, &[image]);
|
||||
assert_eq!((s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width), (fill, stroke, 5.0), "new art stays visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_a_stroke_clears_the_stroke_and_keeps_the_weight() {
|
||||
let mut s = session();
|
||||
let bare = rect(&mut s, 20.0);
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
painted(&mut s, 120.0, "#0000ff", "#000000", 7.0);
|
||||
select(&mut s, &[bare]);
|
||||
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (Paint::None, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_gradient_loads_without_its_place_on_the_object() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 20.0);
|
||||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"stops": stops, "start": [20, 40], "end": [50, 40]}}));
|
||||
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
|
||||
select(&mut s, &[src]);
|
||||
assert!(matches!(s.paint.fill, Paint::Gradient(_)), "{:?}", s.paint.fill);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 220.0);
|
||||
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_leaves_the_stroke_panels_options_for_new_art_alone() {
|
||||
let mut s = session();
|
||||
let plain = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"cap": "round", "dash": [4, 2]}));
|
||||
select(&mut s, &[plain]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 120.0);
|
||||
let st = node(&s, id).appearance.stroke().unwrap().clone();
|
||||
assert_eq!((st.paint.clone(), st.width), (node(&s, plain).appearance.stroke_paint(), 5.0));
|
||||
assert_eq!((st.cap, st.dash.map(|d| d.pattern)), (LineCap::Round, Some(vec![4.0, 2.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sampled_paint_stays_until_the_selection_changes() {
|
||||
let mut s = session();
|
||||
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let sampled = Paint::solid(vectorcraft_color::Color::WHITE);
|
||||
s.paint.fill = sampled.clone();
|
||||
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
||||
assert_eq!(s.paint.fill, sampled, "not a selection command");
|
||||
select(&mut s, &[a]);
|
||||
assert_eq!(s.paint.fill, sampled, "the same selection again");
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(s.paint.fill, sampled, "nothing selected shows nothing to load");
|
||||
select(&mut s, &[a]);
|
||||
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint(), "selected anew");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeting_a_layer_loads_the_paints_of_its_contents() {
|
||||
let mut s = session();
|
||||
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
|
||||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||||
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_option_round_trips_with_the_preferences() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() {
|
||||
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
|
||||
}
|
||||
|
||||
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
|
||||
/// arrows changed, in the saved options and the Corner Radius preference too.
|
||||
#[test]
|
||||
fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
let mut s = session();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let drag = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
|
||||
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
|
||||
// Saves every option, the one the arrow changed included.
|
||||
s.set_tool_option("length", &json!(50));
|
||||
};
|
||||
select(&mut s, "polygon");
|
||||
drag(&mut s);
|
||||
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7));
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
|
||||
select(&mut s, "roundedRectangle");
|
||||
let radius = s.prefs.corner_radius;
|
||||
drag(&mut s);
|
||||
assert_eq!(s.prefs.corner_radius, radius + 1.0);
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.prefs.corner_radius, radius);
|
||||
}
|
||||
|
||||
/// The Fidelity an Alt smoothing drag uses on a selected path.
|
||||
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
|
||||
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -1024,6 +1024,19 @@ fn lasso_picks_gradient_mesh_points() {
|
||||
|
||||
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
|
||||
/// deselected and the others selected, the rest of the selection staying as it was.
|
||||
#[test]
|
||||
fn a_selection_tool_click_gives_new_art_the_objects_fill() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
rect(&mut s, 200.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
gesture(&mut s, "selection", &[(125.0, 125.0), (125.0, 125.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.paint.fill, s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_shift_marquee_toggles_objects() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -402,6 +402,25 @@ impl Session {
|
||||
self.tool_panic.take().map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// The active document (its uid) and its [`crate::History::step`].
|
||||
fn history_step(&self) -> Option<(u64, (u64, u64))> {
|
||||
self.active().map(|d| (d.uid, d.history.step()))
|
||||
}
|
||||
|
||||
/// Tell the active tool when the history moved, or another document became active, since it
|
||||
/// last handled a pointer event or was last told ([`vectorcraft_tools::Tool::history_moved`]).
|
||||
/// A lent selection tool is not told.
|
||||
fn tell_tool_history_moved(&mut self) {
|
||||
if self.lender.is_some() {
|
||||
return;
|
||||
}
|
||||
let now = self.history_step();
|
||||
if self.tool_step.is_some_and(|seen| Some(seen) != now) {
|
||||
self.tool.history_moved();
|
||||
}
|
||||
self.tool_step = now;
|
||||
}
|
||||
|
||||
/// Feed a pointer event to the active tool. Returns requests for the UI.
|
||||
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
self.pointer_event(ev, view, true)
|
||||
@@ -495,7 +514,9 @@ impl Session {
|
||||
return r;
|
||||
}
|
||||
self.leave_draw_inside(ev, view)?;
|
||||
self.tell_tool_history_moved();
|
||||
let ev = &self.lend_selection_tool(ev);
|
||||
let lent = self.lender.is_some();
|
||||
let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
|
||||
if !keep {
|
||||
acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_)));
|
||||
@@ -507,6 +528,11 @@ impl Session {
|
||||
}
|
||||
self.apply_actions(acts)
|
||||
});
|
||||
// The tool's own edits do not move the history for it. A selection tool that Cmd lends
|
||||
// edits for the lender, which hears of the edit at its next event.
|
||||
if !lent {
|
||||
self.tool_step = self.history_step();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Down => self.press_point = Some(self.freeform_point),
|
||||
PointerKind::Up => self.press_point = None,
|
||||
@@ -769,6 +795,8 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
|
||||
// The Blend tool's first pick is not marked after an edit that dropped it.
|
||||
self.tell_tool_history_moved();
|
||||
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
|
||||
// The Type tools draw the ports of selected type themselves.
|
||||
if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") {
|
||||
|
||||
Reference in New Issue
Block a user