Plug-ins: only File › Open lists importers' files, and imported art at the canvas edge keeps an editable artboard

New from Template, Load Character/Paragraph/All Styles, Recolor's Other
Library… and the library panels' Other Library… read files with the
built-in readers, so their open dialogs now use builtin_open_filters(),
which leaves out the extensions only importer plug-ins read. File › Open
keeps open_filters() with the importers' files.

The artboard of a document an importer opens is now kept within
±4,000,000 pt. Art at the edge (a zero-width path at x = 4,000,000, or a
stroke past the edge) gave an artboard beyond it, and every later edit
of the document was rejected.
This commit is contained in:
dotCooCoo
2026-11-08 05:35:23 -08:00
parent 58f5789e9a
commit d65207dc62
10 changed files with 89 additions and 21 deletions
+3 -3
View File
@@ -130,9 +130,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black
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, importer_formats, library_open_filters, open_extensions, open_filters, place_filters,
plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format,
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, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format,
};
#[cfg(test)]
+18 -4
View File
@@ -693,7 +693,7 @@ pub fn open_extensions() -> &'static [&'static str] {
exts
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then one per
/// File › Open's filters: "All readable files" first, then one per readable format, then one per
/// importer plug-in (under its name), then swatch libraries (which open in the library panel),
/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked
/// without running them) and plug-ins (installed).
@@ -701,7 +701,20 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
std::iter::once(("All readable files", open_extensions()))
.chain(format_filters())
.chain(importer_formats().into_iter().map(|f| (f.label, f.extensions)))
.chain(std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)))
.chain(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, 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)))
@@ -715,12 +728,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,
+15 -6
View File
@@ -14,7 +14,7 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::Document;
use vectorcraft_doc::{Document, MAX_COORD};
use vectorcraft_geom::Rect;
use vectorcraft_plugins::{Plugin, export, import, objects};
@@ -101,15 +101,24 @@ pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Resu
let ids =
objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) {
board.rect = at_least_a_point(bounds);
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 })
}
/// `r` at least 1 pt wide and high, about its center: the artboard of a single line or point.
fn at_least_a_point(r: Rect) -> Rect {
let grow = |lo: f64, hi: f64| if hi - lo >= 1.0 { (lo, hi) } else { ((lo + hi) / 2.0 - 0.5, (lo + hi) / 2.0 + 0.5) };
let ((x0, x1), (y0, y1)) = (grow(r.x0, r.x1), grow(r.y0, r.y1));
/// 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)
}
+15
View File
@@ -109,6 +109,21 @@ fn a_file_an_importer_reads_opens_as_editable_art() {
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();
+2 -2
View File
@@ -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());
@@ -135,6 +135,35 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() {
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<std::cell::RefCell<Vec<crate::FilePick>>> = 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<String>)]| 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();
}
/// 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]
+1 -1
View File
@@ -461,7 +461,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> 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() })?
}
};
+1 -1
View File
@@ -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(),
}
}
+1 -1
View File
@@ -421,7 +421,7 @@ pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option<String>) -
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
}
+4 -3
View File
@@ -47,8 +47,8 @@ reads the file, by its content or by its extension, and the importer lists the f
one whose id sorts first opens it. `document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer,
which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg`
only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an
importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high);
the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are
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. These rules are
VectorCraft's own.
In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload
@@ -59,7 +59,8 @@ parameters it runs at once. Parameters too many for the window's height scroll i
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. Importers' files are listed under File ›
Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the
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); **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. 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