mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 14:41:19 +00:00
Merge main into fix/save-screens-unknown-options
This commit is contained in:
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"3D Lighting Preset",
|
||||
[],
|
||||
None,
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}",
|
||||
has_doc,
|
||||
lighting_preset
|
||||
),
|
||||
@@ -505,6 +505,13 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
|
||||
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
|
||||
}
|
||||
// Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options.
|
||||
if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut options = p.clone();
|
||||
let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?;
|
||||
@@ -516,7 +523,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
|
||||
/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one
|
||||
/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still
|
||||
/// fit. → `{ids}`.
|
||||
/// fit; a hidden one has its given values checked. → `{ids}`.
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
@@ -574,7 +581,6 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(e) = effect
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let to = kind.unwrap_or(&e.id);
|
||||
let mut prospective =
|
||||
@@ -582,7 +588,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) {
|
||||
cur.extend(new);
|
||||
}
|
||||
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
|
||||
if visible != Some(false) {
|
||||
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
// Hidden, the effect draws nothing, so its object need not fit. The values given are
|
||||
// checked, and after a change of 3D type so is every value it keeps.
|
||||
let checked = if to == e.id { effects::upgrade_params(to, ¶ms) } else { prospective };
|
||||
effects::validate_three_d_params(to, &checked).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| {
|
||||
|
||||
@@ -91,3 +91,27 @@ fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection_unchanged() {
|
||||
let (mut s, first) = setup("threeD.rotate", Value::Null);
|
||||
let classic = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.classicExtrude","item":null,"params":{"surface":"diffuse","lightRotation":40}})).unwrap();
|
||||
for ids in [vec![classic], vec![first.0, classic]] {
|
||||
for preset in LIGHTING_PRESETS {
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let p = json!({"ids":ids,"index":0,"item":null,"preset":preset.id});
|
||||
let err = s.execute("effect.lightingPreset", &p).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("object {classic}: Extrude & Bevel (Classic) at index 0 has no lighting presets")), "{err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
}
|
||||
}
|
||||
// Its lights are set with its own parameters instead.
|
||||
s.execute("effect.setParams", &json!({"ids":[classic],"index":0,"item":null,"params":{"lightRotation":135,"lightHeight":45}})).unwrap();
|
||||
let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params;
|
||||
assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0)));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,56 @@ fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() {
|
||||
assert_eq!(effects_for_test(&s, id), before);
|
||||
}
|
||||
|
||||
/// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it:
|
||||
/// a bad value is refused and the document is left as it was, and good values are stored.
|
||||
#[test]
|
||||
fn hiding_a_3d_effect_still_checks_its_new_values() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let rect = |s: &mut Session, x: u32| {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let classic = rect(&mut s, 20);
|
||||
s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap();
|
||||
let extrude = rect(&mut s, 120);
|
||||
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap();
|
||||
let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap();
|
||||
for (id, params, why) in [
|
||||
(classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"),
|
||||
(classic, json!({"surface": "glossy"}), "`surface` must be one of"),
|
||||
(classic, json!({"bevel": "wavy"}), "bevel must be none or one of"),
|
||||
(extrude, json!({"lights": []}), "lights must contain one to ten lights"),
|
||||
(extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"),
|
||||
(profile, json!({"edge": "top"}), "Revolve edge must be left or right"),
|
||||
] {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false});
|
||||
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{params}: {e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{params}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}");
|
||||
}
|
||||
// A hidden switch of 3D type checks the values given with it for the new type.
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false});
|
||||
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("lights must contain one to ten lights"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// Hiding alone checks nothing.
|
||||
s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap();
|
||||
assert!(!node(&s, extrude).appearance.effects[0].visible);
|
||||
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap();
|
||||
let e = &node(&s, classic).appearance.effects[0];
|
||||
assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false));
|
||||
// A value the effect already holds is not checked again: a key it does not take, as a file
|
||||
// can carry, does not stop a hidden edit of another value.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1);
|
||||
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap();
|
||||
assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0));
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
|
||||
/// exported, expanded and undone; one 3D effect per object.
|
||||
#[test]
|
||||
|
||||
@@ -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;
|
||||
@@ -136,9 +139,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)
|
||||
@@ -535,8 +547,8 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool {
|
||||
}
|
||||
|
||||
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
|
||||
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id
|
||||
/// unless `grant` lists them all, one that would take another module's place ([`takes_over`])
|
||||
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this very
|
||||
/// module unless `grant` lists them all, one that would take another module's place ([`takes_over`])
|
||||
/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with
|
||||
/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -581,8 +593,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
}
|
||||
// A plug-in that lists permissions installs once the user grants them: whoever installs it
|
||||
// asks them first, unless they already granted all of them (with a plug-in store, to this
|
||||
// module).
|
||||
// asks them first, unless they already granted this very module all of them.
|
||||
let m = plugin.manifest();
|
||||
let wanted = m.permissions();
|
||||
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
|
||||
@@ -590,13 +601,22 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let asked = permissions::names(&wanted).join(", ");
|
||||
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541). The module is written first and its
|
||||
// grant only once it is there; when the grant can't be written, the store gets the module it
|
||||
// held back. A failed write of that module is only logged, and the grant then doesn't cover
|
||||
// the new module. A stored module that can't be read back isn't replaced by one that needs a
|
||||
// grant.
|
||||
let previous = s.stored_plugin_bytes(plugin.id());
|
||||
let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes);
|
||||
if m.asks_permissions() {
|
||||
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
|
||||
registry::grant(g.clone());
|
||||
s.store_grant(&m.id, Some(g));
|
||||
if stored && !s.store_grant(&m.id, Some(g)) {
|
||||
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
|
||||
stored = false;
|
||||
}
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541), with their settings and cache.
|
||||
let stored = s.store_plugin(plugin.id(), &bytes);
|
||||
// It gets the settings and cache kept for its id.
|
||||
s.attach_plugin_storage(plugin.id());
|
||||
// Installing a disabled plug-in again (an updated version) turns it on for the next launch too,
|
||||
// also when it is disabled by its stored file alone (it stopped a launch while loading). When
|
||||
@@ -851,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
|
||||
),
|
||||
@@ -878,7 +898,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -887,7 +907,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
@@ -914,7 +934,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user