Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0ea352df | ||
|
|
206e81c3b7 | ||
|
|
24afe0f517 | ||
|
|
ec67ed9646 | ||
|
|
ce558ffb73 | ||
|
|
cbe7f83881 | ||
|
|
feb319027e | ||
|
|
69e77570b4 | ||
|
|
4159ffe6fd | ||
|
|
51e7044b0e | ||
|
|
dae13d57f0 | ||
|
|
3374d0778d | ||
|
|
04bb6d6f27 | ||
|
|
ef0ab7ba9d | ||
|
|
546c81624a | ||
|
|
13bd24ba79 | ||
|
|
eb25a85d90 | ||
|
|
504e719230 | ||
|
|
d443cb031c | ||
|
|
41afc9f3e5 | ||
|
|
4d62e0d0ef | ||
|
|
1d1e28a51f | ||
|
|
f870f3ac09 | ||
|
|
67dcfd4fc4 | ||
|
|
972945f80d | ||
|
|
62036eac6c | ||
|
|
c49fb30032 | ||
|
|
0cc7e59df0 | ||
|
|
26ec0464bb | ||
|
|
f0810ca58b | ||
|
|
35d6aa0a16 | ||
|
|
36dc6e0c11 | ||
|
|
791bd91d5c | ||
|
|
17314d6b61 | ||
|
|
544b17ec33 | ||
|
|
5e8581e42f | ||
|
|
63686e659e | ||
|
|
8a91be7983 |
No files matched your search
Generated
+9
@@ -4654,6 +4654,7 @@ dependencies = [
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-scriptcheck",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -4689,6 +4690,7 @@ version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4822,6 +4824,13 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.10.0"
|
||||
|
||||
@@ -37,6 +37,7 @@ vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -65,6 +66,7 @@ skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.5"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
rustix = { version = "1.1", default-features = false }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
|
||||
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
|
||||
|
||||
@@ -93,7 +93,8 @@ USAGE:
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read.
|
||||
draws differently from its PDF part (which can be older), as long as it can be read. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
@@ -237,19 +238,24 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let mut h = Headless::new();
|
||||
if editing_data {
|
||||
let opened = if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?
|
||||
} else {
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
}
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
|
||||
};
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
|
||||
out.insert("importWarnings".into(), w.clone());
|
||||
}
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -99,3 +99,19 @@ fn info_reports_why_an_eps_read_partly() {
|
||||
assert!(w.contains("`frobnicate`"), "{v}");
|
||||
assert_eq!(v["kinds"]["Path"], 1, "{v}");
|
||||
}
|
||||
|
||||
/// #1604: convert reports what reading the input left out (here a missing linked image) as
|
||||
/// importWarnings, next to the export's own warnings.
|
||||
#[test]
|
||||
fn convert_reports_import_warnings() {
|
||||
let svg = tmp("missing-link.svg");
|
||||
std::fs::write(
|
||||
&svg,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image width="40" height="40" href="nowhere-to-be-found.png"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let png = tmp("missing-link.png");
|
||||
let out: Value = serde_json::from_str(ok(&["convert", &svg, &png]).trim()).unwrap();
|
||||
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
|
||||
}
|
||||
@@ -93,3 +93,8 @@ sync_github_name = true
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lewandowskit]
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
@@ -116,10 +116,36 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
(None, None) => plain_outline(line, &style(st, width), tol),
|
||||
(None, None) => {
|
||||
let mut out = plain_outline(line, &style(st, width), tol);
|
||||
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
|
||||
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
|
||||
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
|
||||
/// (SVG 2, "Path data": zero-length path segments).
|
||||
fn point_dots(line: &BezPath) -> Vec<Dot> {
|
||||
let els = line.elements();
|
||||
subpaths(line)
|
||||
.into_iter()
|
||||
.filter_map(|(r, _)| {
|
||||
let sub = els.get(r)?;
|
||||
let at = sub.first()?.end_point()?;
|
||||
let still = sub.iter().all(|e| match e {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
|
||||
PathEl::QuadTo(a, b) => *a == at && *b == at,
|
||||
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
|
||||
PathEl::ClosePath => true,
|
||||
});
|
||||
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `line` stroked with `style`, to fill with the non-zero rule.
|
||||
///
|
||||
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
|
||||
|
||||
@@ -26,6 +26,7 @@ vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
||||
@@ -382,12 +382,16 @@ 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")?;
|
||||
open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p)
|
||||
// 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);
|
||||
open_bytes_with(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.clone(), p)
|
||||
}
|
||||
|
||||
pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, "file.newFromTemplate")?;
|
||||
open_template(s, src.name, &src.bytes, src.path)
|
||||
let path = src.path.map(super::absolute_path);
|
||||
open_template(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.as_deref())
|
||||
}
|
||||
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
|
||||
@@ -674,7 +674,7 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
|
||||
|
||||
/// 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 and print
|
||||
/// presets (imported).
|
||||
/// 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())
|
||||
@@ -683,6 +683,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
@@ -766,6 +767,20 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the file at `path` (all of it when it is shorter). Anything but a
|
||||
/// regular file (a folder, a pipe, a device) is refused, also through a link.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
may_read(path)?;
|
||||
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
||||
return Err(EngineError::Other(format!("{path}: not a file")));
|
||||
}
|
||||
let mut out = vec![];
|
||||
std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
may_write(path)?;
|
||||
@@ -794,7 +809,7 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
may_write(path)?;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
|
||||
let written = f.write_all(bytes).and_then(|()| f.sync_all());
|
||||
let written = f.write_all(bytes).and_then(|()| vectorcraft_format::sync_file(&f));
|
||||
drop(f);
|
||||
written.map_err(|e| {
|
||||
// Best effort: the partly written file is all there is to clean up.
|
||||
@@ -944,6 +959,11 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn read_file_head(path: &str, _: usize) -> Result<Vec<u8>> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
|
||||
@@ -159,9 +159,10 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
7 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
|
||||
8 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
|
||||
);
|
||||
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Bounding Box, Rasterize, Crop Image, Mask (an image), Create Trim Marks, Convert to Shape and
|
||||
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -209,7 +210,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut hits: Vec<NodeId> = vec![];
|
||||
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
|
||||
// ancestor directly inside the layer.
|
||||
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
|
||||
for id in &roots {
|
||||
let Some(layer) = d.layer_of(*id) else { continue };
|
||||
// The selected object's ancestor directly inside the layer.
|
||||
@@ -218,12 +221,26 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let top = anc.get(pos + 1).copied().unwrap_or(*id);
|
||||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
|
||||
let Some((_, idx, _)) = d.position(top) else { continue };
|
||||
for n in d.children(Some(layer)).into_iter().flatten().skip(idx + 1) {
|
||||
if roots.contains(&n.id) || hits.contains(&n.id) {
|
||||
selected.entry(layer).or_default().push((idx, b));
|
||||
}
|
||||
// The loop walks up each layer once. Each object that is not selected and overlaps a selected
|
||||
// object below it is locked (Lock) or hidden (Hide).
|
||||
let roots: HashSet<NodeId> = roots.into_iter().collect();
|
||||
let mut hits: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for (layer, mut below) in selected {
|
||||
below.sort_by_key(|(idx, _)| *idx);
|
||||
let mut below = below.into_iter().peekable();
|
||||
// `under` holds the bounds of the selected objects below the object at `i`.
|
||||
let mut under: Vec<Rect> = vec![];
|
||||
for (i, n) in d.children(Some(layer)).into_iter().flatten().enumerate() {
|
||||
while let Some((_, b)) = below.next_if(|(idx, _)| *idx < i) {
|
||||
under.push(b);
|
||||
}
|
||||
if under.is_empty() || roots.contains(&n.id) {
|
||||
continue;
|
||||
}
|
||||
if n.visual_bounds().is_some_and(|nb| nb.intersect(b).area() > 0.0 || nb.contains(b.center())) {
|
||||
hits.push(n.id);
|
||||
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
hits.insert(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ pub mod printtiling;
|
||||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
pub mod scripts;
|
||||
mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub(crate) mod slices;
|
||||
@@ -278,6 +279,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(printpresets::specs());
|
||||
v.extend(printtiling::specs());
|
||||
v.extend(plugin::specs());
|
||||
v.extend(scripts::specs());
|
||||
v.extend(cut::specs());
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `script.check`: a script written for the reference app (`.jsx`, `.jsxinc`, `.js`, or a compiled
|
||||
//! `.jsxbin`) checked without running any of it, by `vectorcraft-scriptcheck`: what kind of file it
|
||||
//! is, what it needs, its directives and includes, the platform features and document-model member
|
||||
//! chains it uses, the menu commands it runs, what it does outside the document, and its problems.
|
||||
//! With a path and `includes`, the files its `#include` lines name are read from inside its folder,
|
||||
//! each through the same checks as every other file a command reads.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report};
|
||||
|
||||
/// The ids a reply can hold, for the windows that show it.
|
||||
pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The extensions of the scripts File › Open shows a check for.
|
||||
pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "script.check",
|
||||
"Check Script",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)",
|
||||
always,
|
||||
check
|
||||
)]
|
||||
}
|
||||
|
||||
/// The folder a script is in, which its included files are read from.
|
||||
struct Folder {
|
||||
dir: std::path::PathBuf,
|
||||
/// The folder with its links followed (none where the file system can't tell).
|
||||
real: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
/// The folder of the script at `path`.
|
||||
fn of(path: &str) -> Self {
|
||||
let dir = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||||
Folder { real: std::fs::canonicalize(dir).ok(), dir: dir.to_path_buf() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader for Folder {
|
||||
fn load(&mut self, path: &str, max: usize) -> Loaded {
|
||||
let file = self.dir.join(path);
|
||||
// A link that leads out of the script's folder isn't followed.
|
||||
if let (Some(real), Ok(target)) = (&self.real, std::fs::canonicalize(&file))
|
||||
&& !target.starts_with(real)
|
||||
{
|
||||
return Loaded::Refused;
|
||||
}
|
||||
let file = file.to_string_lossy();
|
||||
if crate::file_access::check_read(&file).is_err() {
|
||||
return Loaded::Refused;
|
||||
}
|
||||
super::fileio::read_file_head(&file, max).map_or(Loaded::Missing, Loaded::Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is
|
||||
/// decoded.
|
||||
fn decode_head(b64: &str, max: usize) -> Option<Vec<u8>> {
|
||||
let chars = max.div_ceil(3).saturating_mul(4);
|
||||
let mut kept = 0usize;
|
||||
let end = b64
|
||||
.bytes()
|
||||
.position(|b| {
|
||||
kept += usize::from(!b.is_ascii_whitespace());
|
||||
kept > chars
|
||||
})
|
||||
.unwrap_or(b64.len());
|
||||
vectorcraft_format::base64_decode(b64.get(..end)?)
|
||||
}
|
||||
|
||||
fn check(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "script.check";
|
||||
let limits = Limits::default();
|
||||
// One byte more than a file may have shows that the file is too large.
|
||||
let max = limits.file_bytes.saturating_add(1);
|
||||
let path = str_param(p, "path");
|
||||
let bytes: std::borrow::Cow<[u8]> = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => super::fileio::read_file_head(path, max)?.into(),
|
||||
(None, Some(text), _) => text.as_bytes().get(..max).unwrap_or(text.as_bytes()).into(),
|
||||
(None, None, Some(b64)) => decode_head(b64, max).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let name = super::fileio::file_name(str_param(p, "name").or(path).unwrap_or_default());
|
||||
// With a path, the file is checked under its own name, which its includes are followed from;
|
||||
// `name` names the reply.
|
||||
let file = path.map(super::fileio::file_name).unwrap_or_else(|| name.clone());
|
||||
let mut folder = path.filter(|_| bool_or(p, "includes", false)).map(Folder::of);
|
||||
let report = vectorcraft_scriptcheck::check(&file, &bytes, folder.as_mut().map(|f| f as &mut dyn Loader), &limits);
|
||||
Ok(report_json(&name, &report))
|
||||
}
|
||||
|
||||
/// Object `v` with `key: value` added when there is a value.
|
||||
fn with(mut v: Value, key: &str, value: Option<impl serde::Serialize>) -> Value {
|
||||
if let (Some(o), Some(x)) = (v.as_object_mut(), value) {
|
||||
o.insert(key.into(), json!(x));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The reply for report `r` on the script `name`.
|
||||
fn report_json(name: &str, r: &Report) -> Value {
|
||||
let targets: Vec<Value> = r
|
||||
.targets
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let v = json!({"value": t.value, "app": t.app, "line": t.line});
|
||||
let v = with(v, "instance", t.instance.as_deref());
|
||||
let v = with(v, "version", t.version.as_deref());
|
||||
let v = with(v, "locale", t.locale.as_deref());
|
||||
with(v, "file", t.file.as_deref())
|
||||
})
|
||||
.collect();
|
||||
let directives: Vec<Value> = r
|
||||
.directives
|
||||
.iter()
|
||||
.map(|d| with(json!({"name": d.name, "value": d.value, "line": d.line, "form": d.form}), "file", d.file.as_deref()))
|
||||
.collect();
|
||||
let includes: Vec<Value> = r
|
||||
.includes
|
||||
.iter()
|
||||
.map(|i| with(with(json!({"value": i.value, "line": i.line, "status": i.status.id()}), "path", i.path.as_deref()), "file", i.file.as_deref()))
|
||||
.collect();
|
||||
let features: Vec<Value> = r.features.iter().map(|f| json!({"id": f.id, "count": f.count, "lines": f.lines})).collect();
|
||||
let members: Vec<Value> = r.members.iter().map(|m| json!({"path": m.name, "count": m.count})).collect();
|
||||
let types: Vec<Value> = r.types.iter().map(|t| json!({"name": t.name, "count": t.count})).collect();
|
||||
let strings: Vec<Value> = r.menu_strings.iter().map(|s| json!({"value": s.value, "count": s.count, "lines": s.lines})).collect();
|
||||
let outside: Vec<Value> = r
|
||||
.outside
|
||||
.iter()
|
||||
.map(|o| with(json!({"id": o.id, "count": o.count, "lines": o.lines}), "modes", Some(&o.modes).filter(|m| !m.is_empty())))
|
||||
.collect();
|
||||
let problems: Vec<Value> = r
|
||||
.problems
|
||||
.iter()
|
||||
.map(|p| with(with(with(json!({"code": p.code}), "line", p.line), "file", p.file.as_deref()), "detail", p.detail.as_deref()))
|
||||
.collect();
|
||||
json!({
|
||||
"name": name,
|
||||
"kind": r.kind.id(),
|
||||
"needs": r.needs.iter().map(|n| n.id()).collect::<Vec<_>>(),
|
||||
"lines": r.lines,
|
||||
"targets": targets,
|
||||
"directives": directives,
|
||||
"includes": includes,
|
||||
"features": features,
|
||||
"dom": {"members": members, "types": types},
|
||||
"menuCommands": {"strings": strings, "dynamic": r.menu_dynamic},
|
||||
"outside": outside,
|
||||
"problems": problems,
|
||||
"truncated": r.truncated,
|
||||
})
|
||||
}
|
||||
@@ -230,7 +230,12 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
|
||||
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
|
||||
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
|
||||
return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)")));
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs; prefix - to turn off)"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
|
||||
}
|
||||
|
||||
@@ -1917,6 +1917,8 @@ mod tests_saveoptions;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
#[cfg(test)]
|
||||
mod tests_shaper;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
|
||||
@@ -22,13 +22,13 @@ fn square_at(k: usize) -> Rect {
|
||||
}
|
||||
|
||||
/// Square `k` ([`square_at`]) as a path with a new id.
|
||||
fn square(d: &mut Document, k: usize) -> Node {
|
||||
pub(crate) fn square(d: &mut Document, k: usize) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default_art())
|
||||
}
|
||||
|
||||
/// A session whose document holds `make(d, k)` for each `k` below `n` on its one layer, all of it
|
||||
/// selected.
|
||||
fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
|
||||
pub(crate) fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
|
||||
let mut d = Document::new(ROW as f64 * 20.0, (n / ROW + 1) as f64 * 20.0);
|
||||
let art: Vec<Arc<Node>> = (0..n).map(|k| Arc::new(make(&mut d, k))).collect();
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
@@ -46,7 +46,7 @@ fn ids_of(n: &Node) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
/// The ids of the objects on layer `i`.
|
||||
fn layer(s: &Session, i: usize) -> Vec<NodeId> {
|
||||
pub(crate) fn layer(s: &Session, i: usize) -> Vec<NodeId> {
|
||||
ids_of(&s.doc().unwrap().doc.layers[i])
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fn soon<T: Send + 'static>(mut s: Session, what: &'static str, f: impl FnOnce(&m
|
||||
}
|
||||
|
||||
/// Runs command `cmd` with `params` through [`soon`] and returns its result.
|
||||
fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
|
||||
pub(crate) fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
|
||||
let (s, r) = soon(s, cmd, move |s| s.execute(cmd, ¶ms));
|
||||
(s, r.unwrap())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ fn ids_in(r: &Value) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
/// Runs Undo and checks that it gives back `before`.
|
||||
fn undo_gives_back(s: &mut Session, before: &Document) {
|
||||
pub(crate) fn undo_gives_back(s: &mut Session, before: &Document) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::tests_large_edits::{layer, run_soon, square, undo_gives_back};
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
@@ -137,6 +138,100 @@ fn hide_all_artwork_above_deselects_the_objects_it_hides() {
|
||||
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
|
||||
}
|
||||
|
||||
/// Lock › All Artwork Above and Hide › All Artwork Above take each object that overlaps a selected
|
||||
/// object below it in the same layer, once, whether it is locked or hidden already or not. An object
|
||||
/// that overlaps only a selected object above it, the group around a selected object, the selected
|
||||
/// objects and an object that lies over a selected object of another layer stay as they are.
|
||||
#[test]
|
||||
fn lock_and_hide_all_artwork_above_take_what_overlaps_a_selected_object_below_in_its_layer() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
// `b` overlaps only `c`, which lies above it.
|
||||
let b = rect(&mut s, 250.0, 0.0, 100.0, 100.0);
|
||||
let c = rect(&mut s, 90.0, 0.0, 200.0, 100.0);
|
||||
// `d` overlaps `a`, `e` overlaps `c`, `f` overlaps both, `g` overlaps neither, and `k` overlaps `a` and is locked.
|
||||
let d = rect(&mut s, 20.0, 50.0, 30.0, 30.0);
|
||||
let e = rect(&mut s, 200.0, 50.0, 30.0, 30.0);
|
||||
let f = rect(&mut s, 60.0, 10.0, 50.0, 20.0);
|
||||
let g = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
|
||||
let k = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
|
||||
// `m` is in a group, and `n` overlaps it.
|
||||
let m = rect(&mut s, 800.0, 0.0, 50.0, 50.0);
|
||||
sel(&mut s, &[m]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let n = rect(&mut s, 820.0, 20.0, 50.0, 50.0);
|
||||
// In another layer, `h` lies where `a` lies, and `j` overlaps `i`.
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let h = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let i = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
|
||||
let j = rect(&mut s, 650.0, 50.0, 100.0, 100.0);
|
||||
let (taken, kept) = ([d, e, f, k, n, j], [a, b, c, g, group, m, h, i]);
|
||||
for (cmd, lock) in [("object.lock.above", true), ("object.hide.above", false)] {
|
||||
let done = |s: &Session, id: NodeId| if lock { node(s, id).locked } else { !node(s, id).visible };
|
||||
sel(&mut s, &[a, c, m, i]);
|
||||
let steps = undo_len(&s);
|
||||
assert_eq!(s.execute(cmd, &json!({})).unwrap(), json!({"count": 6}), "{cmd}");
|
||||
assert!(taken.iter().all(|id| done(&s, *id)), "{cmd}");
|
||||
assert!(!kept.iter().any(|id| done(&s, *id)), "{cmd}");
|
||||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, c, m, i]), "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A session with 1,000 tiles of 100 x 200 pt in rows of 40 under the 50,000 squares of [`square`],
|
||||
/// 50 on each tile, all of it selected; and the ids of the tiles and of the squares.
|
||||
fn tiles_under_squares() -> (Session, Vec<NodeId>, Vec<NodeId>) {
|
||||
let tile = |t: usize| {
|
||||
let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0);
|
||||
Rect::new(x, y, x + 100.0, y + 200.0)
|
||||
};
|
||||
let s = super::tests_large_edits::session(51_000, |d, k| {
|
||||
if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) }
|
||||
});
|
||||
let mut tiles = layer(&s, 0);
|
||||
let squares = tiles.split_off(1000);
|
||||
(s, tiles, squares)
|
||||
}
|
||||
|
||||
/// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one
|
||||
/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay
|
||||
/// selected.
|
||||
#[test]
|
||||
fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() {
|
||||
let (mut s, tiles, squares) = tiles_under_squares();
|
||||
sel(&mut s, &tiles);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "object.lock.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 50_000}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| n.locked)), "every square is locked");
|
||||
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| !n.locked)), "no tile is locked");
|
||||
assert!(selected(&s) == tiles, "the tiles stay selected");
|
||||
undo_gives_back(&mut s, &before);
|
||||
let (s, r) = run_soon(s, "object.hide.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 50_000}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| !n.visible)), "every square is hidden");
|
||||
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| n.visible)), "no tile is hidden");
|
||||
assert!(selected(&s) == tiles, "the tiles stay selected");
|
||||
}
|
||||
|
||||
/// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide ›
|
||||
/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step.
|
||||
#[test]
|
||||
fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() {
|
||||
let (s, tiles, squares) = tiles_under_squares();
|
||||
let (steps, all) = (undo_len(&s), [tiles, squares].concat());
|
||||
assert!(selected(&s) == all, "everything is selected");
|
||||
let (s, r) = run_soon(s, "object.lock.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 0}));
|
||||
let (s, r) = run_soon(s, "object.hide.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 0}));
|
||||
assert_eq!(undo_len(&s), steps);
|
||||
assert!(selected(&s) == all, "everything stays selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hide_other_layers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `script.check`: scripts written for the reference app checked without running them, from a path,
|
||||
//! text or base64; the files their `#include` lines name read from the script's folder when asked,
|
||||
//! through the automation read root; and the limits.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn check(p: Value) -> Value {
|
||||
Session::new().execute("script.check", &p).unwrap_or_else(|e| panic!("script.check {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A fresh folder for the files of test `name`.
|
||||
fn folder(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vc-script-check-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("scripts/lib")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn statuses(r: &Value) -> Vec<&str> {
|
||||
r["includes"].as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_reported_without_running_it() {
|
||||
let src = concat!(
|
||||
"#target acme-12\n",
|
||||
"var doc = app.activeDocument;\n",
|
||||
"app.executeMenuCommand('acmecopyall');\n",
|
||||
"var f = new File(p); f.open('w');\n",
|
||||
"alert('done');\n",
|
||||
);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("script.check", &json!({"data": src, "name": "/Users/me/Tidy Layers.jsx"})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["kind"].as_str(), r["lines"].as_u64()), (Some("Tidy Layers.jsx"), Some("script"), Some(5)));
|
||||
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands"]));
|
||||
assert_eq!(r["targets"], json!([{"value": "acme-12", "app": "acme", "version": "12", "line": 1}]));
|
||||
assert_eq!(r["directives"], json!([{"name": "target", "value": "acme-12", "line": 1, "form": "#"}]));
|
||||
assert_eq!(r["menuCommands"], json!({"strings": [{"value": "acmecopyall", "count": 1, "lines": [3]}], "dynamic": 0}));
|
||||
assert_eq!(r["outside"], json!([{"id": "filesWritten", "count": 1, "lines": [4], "modes": ["w"]}]));
|
||||
assert_eq!(r["dom"], json!({"members": [{"path": "app.activeDocument", "count": 1}], "types": []}));
|
||||
let notify = r["features"].as_array().unwrap().iter().find(|f| f["id"] == "notify").unwrap();
|
||||
assert_eq!(notify, &json!({"id": "notify", "count": 1, "lines": [5]}));
|
||||
assert_eq!((r["problems"].clone(), r["includes"].clone(), r["truncated"].clone()), (json!([]), json!([]), json!(false)));
|
||||
// Nothing ran: the document has no new undo step, and the check isn't recorded.
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert!(!find_command("script.check").unwrap().journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_text_and_base64_give_one_report() {
|
||||
let dir = folder("forms");
|
||||
let src = "#include 'lib/x.jsxinc'\nvar s = 'open;\n";
|
||||
let path = dir.join("scripts/Tidy.jsx");
|
||||
std::fs::write(&path, src).unwrap();
|
||||
let by_path = check(json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(by_path["name"], "Tidy.jsx");
|
||||
assert_eq!(by_path["problems"], json!([{"code": "unterminatedString", "line": 2}]));
|
||||
assert_eq!(check(json!({"data": src, "name": "Tidy.jsx"})), by_path);
|
||||
assert_eq!(check(json!({"dataBase64": vectorcraft_format::base64_encode(src.as_bytes()), "name": "Tidy.jsx"})), by_path);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_are_read_from_the_scripts_folder_only_when_asked() {
|
||||
let dir = folder("includes");
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/net.jsxinc'\n#include '../outside.jsxinc'\n#include 'lib/none.jsxinc'\n").unwrap();
|
||||
std::fs::write(dir.join("scripts/lib/net.jsxinc"), "var s = new Socket();\n").unwrap();
|
||||
std::fs::write(dir.join("outside.jsxinc"), "alert('outside');\n").unwrap();
|
||||
let path = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
let r = check(json!({"path": path}));
|
||||
assert_eq!(statuses(&r), ["notRead", "notRead", "notRead"]);
|
||||
assert_eq!(r["needs"], json!(["language"]));
|
||||
let r = check(json!({"path": path, "includes": true}));
|
||||
assert_eq!(statuses(&r), ["checked", "notFollowed", "missing"]);
|
||||
assert_eq!(r["includes"][0]["path"], "lib/net.jsxinc");
|
||||
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
|
||||
assert_eq!(r["outside"], json!([{"id": "network", "count": 1, "lines": []}]), "an included file's lines aren't the script's");
|
||||
// Text has no folder to read from.
|
||||
let r = check(json!({"data": "#include 'lib/net.jsxinc'\n", "includes": true}));
|
||||
assert_eq!(statuses(&r), ["notRead"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_given_with_a_path_names_the_reply_and_the_includes_follow_the_path() {
|
||||
let dir = folder("named");
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'helper.jsx'\n").unwrap();
|
||||
std::fs::write(dir.join("scripts/helper.jsx"), "var s = new Socket();\n").unwrap();
|
||||
let path = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
let r = check(json!({"path": path, "name": "helper.jsx", "includes": true}));
|
||||
assert_eq!(r["name"], "helper.jsx");
|
||||
assert_eq!(statuses(&r), ["checked"]);
|
||||
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_the_folder_and_the_automation_read_root_keep_reads_inside() {
|
||||
let dir = folder("root");
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/link.jsxinc'\n#include 'lib/ok.jsxinc'\n").unwrap();
|
||||
std::fs::write(dir.join("scripts/lib/ok.jsxinc"), "alert(1);\n").unwrap();
|
||||
std::fs::write(dir.join("secret.jsxinc"), "var s = new Socket();\n").unwrap();
|
||||
std::os::unix::fs::symlink(dir.join("secret.jsxinc"), dir.join("scripts/lib/link.jsxinc")).unwrap();
|
||||
std::fs::write(dir.join("other.jsx"), "alert(2);\n").unwrap();
|
||||
let main = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
// A link that leads out of the script's folder isn't followed.
|
||||
let r = check(json!({"path": main, "includes": true}));
|
||||
assert_eq!(statuses(&r), ["notFollowed", "checked"]);
|
||||
assert_eq!(r["outside"], json!([]));
|
||||
// Under a read root, the script and its includes are read inside it, and a script outside isn't.
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&dir.join("scripts")), None).unwrap().unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": main, "includes": true}))).unwrap();
|
||||
assert_eq!(statuses(&r), ["notFollowed", "checked"]);
|
||||
let other = dir.join("other.jsx").to_string_lossy().into_owned();
|
||||
let e = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": other}))).unwrap_err();
|
||||
assert!(e.to_string().contains("outside the read root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pipes_and_folders_are_not_read() {
|
||||
let dir = folder("pipes");
|
||||
let pipe = dir.join("scripts/lib/pipe.jsxinc");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&pipe).status().unwrap().success());
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/pipe.jsxinc'\n#include 'lib'\n").unwrap();
|
||||
let main = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
let pipe = pipe.to_string_lossy().into_owned();
|
||||
// Opening a pipe that has no writer waits for one: the checks run on a thread with a time limit.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut s = Session::new();
|
||||
let included = s.execute("script.check", &json!({"path": main, "includes": true})).map(|r| r["includes"].clone());
|
||||
let direct = s.execute("script.check", &json!({ "path": pipe })).map_err(|e| e.to_string());
|
||||
let _ = tx.send((included.map_err(|e| e.to_string()), direct));
|
||||
});
|
||||
let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the checks return");
|
||||
let statuses: Vec<String> = included.unwrap().as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(statuses, ["missing", "missing"]);
|
||||
assert!(direct.is_err_and(|e| e.contains("not a file")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_limits_and_bad_params() {
|
||||
let kind = |p: Value| check(p)["kind"].as_str().unwrap().to_string();
|
||||
assert_eq!(kind(json!({"data": "@JSXBIN@ES@2.0@AcmeBody", "name": "a.jsx"})), "compiled");
|
||||
assert_eq!(kind(json!({"data": "alert(1);", "name": "a.jsxbin"})), "compiled");
|
||||
assert_eq!(kind(json!({"data": "import a from 'a';\nexport default a;\n"})), "module");
|
||||
assert_eq!(kind(json!({"dataBase64": vectorcraft_format::base64_encode(b"var a;\0")})), "notText");
|
||||
// Past 8 MiB as text and as base64: only the head is decoded.
|
||||
let big = "a".repeat((8 << 20) + 1);
|
||||
assert_eq!(kind(json!({"data": big})), "tooLarge");
|
||||
assert_eq!(kind(json!({"dataBase64": vectorcraft_format::base64_encode(big.as_bytes())})), "tooLarge");
|
||||
assert_eq!(kind(json!({"data": &big[1..]})), "script");
|
||||
let mut s = Session::new();
|
||||
for (p, want) in [
|
||||
(json!({}), "give `path`, `data` or `dataBase64`"),
|
||||
(json!({"dataBase64": "@@@"}), "bad dataBase64"),
|
||||
(json!({"path": std::env::temp_dir().join("vc-no-such-script.jsx").to_string_lossy()}), "vc-no-such-script.jsx"),
|
||||
] {
|
||||
let e = s.execute("script.check", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(want), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,8 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() {
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))],
|
||||
// Character variants aren't rendered yet (stylistic sets are, #1607).
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"cv01"))), (tag(b"Valu"), F::I32(1))],
|
||||
],
|
||||
),
|
||||
)],
|
||||
@@ -169,7 +171,7 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() {
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs[0].text, "Mixed");
|
||||
assert!(t.runs[0].style.all_caps);
|
||||
assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]);
|
||||
assert_eq!(t.runs[0].style.features, ["-liga", "ss01", "cv01"]);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered")));
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
|
||||
assert_eq!(texts(&restored)[0], t);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
|
||||
//! font files a folder search reads (damaged fonts, collections whose headers give any count of
|
||||
//! faces).
|
||||
//! faces), nor scripts written for the reference app checked without running them (garbage,
|
||||
//! generated script text and mutated scripts).
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
@@ -2327,3 +2328,115 @@ proptest! {
|
||||
search_reads("mutated font file", &bytes)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- scripts checked without running them ----------
|
||||
|
||||
/// A script written for these tests, with invented names: directives in both forms, member chains,
|
||||
/// files, a dialog, menu commands, E4X, an operator and a message to another app.
|
||||
const SCRIPT_SAMPLE: &str = concat!(
|
||||
"#target acme_2-12-en_US\n",
|
||||
"//@include \"lib/util.jsxinc\"\n",
|
||||
"#includepath \"lib;shared\"\n",
|
||||
"#targetengine session\n",
|
||||
"var doc = app.activeDocument;\n",
|
||||
"var w = new Window(\"dialog { text: 'Tidy' }\");\n",
|
||||
"var f = File.saveDialog('Save'); f.open('w'); f.write(doc.acmeName); f.close();\n",
|
||||
"app.executeMenuCommand('acmecopyall');\n",
|
||||
"var x = <root><item id=\"1\">{doc.acmeName}</item></root>;\n",
|
||||
"for each (var i in x..item) { $.writeln(i.@id); }\n",
|
||||
"Vec.prototype['+'] = function (o) { return o; };\n",
|
||||
"var bt = new BridgeTalk(); bt.target = 'acme'; bt.send();\n",
|
||||
"if (item.typename == 'AcmeShape') { alert(/a[/]b/g.test(s) ? 'yes' : \"no\"); }\n",
|
||||
);
|
||||
|
||||
/// The pieces generated scripts are made of.
|
||||
const SCRIPT_PIECES: &[&str] = &[
|
||||
"#target acme",
|
||||
"//@include 'a.jsxinc'",
|
||||
"#includepath 'x;..'",
|
||||
"#pragma",
|
||||
"app",
|
||||
"activeDocument",
|
||||
"selection",
|
||||
"var doc = ",
|
||||
".",
|
||||
".acmeItems",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
";",
|
||||
"=",
|
||||
"'str'",
|
||||
"\"open",
|
||||
"/re[/]x/g",
|
||||
"/",
|
||||
"<a b=\"{v}\">",
|
||||
"</a>",
|
||||
"<!--",
|
||||
".@",
|
||||
"..",
|
||||
"`t",
|
||||
"=>",
|
||||
"\n",
|
||||
"\r",
|
||||
"\u{2028}",
|
||||
"@JSXBIN@",
|
||||
"executeMenuCommand",
|
||||
"new File(p)",
|
||||
"Window(",
|
||||
"import",
|
||||
"/*",
|
||||
"\\",
|
||||
"0x1F",
|
||||
" ",
|
||||
];
|
||||
|
||||
/// `script.check` with `p`, which mustn't panic, and whose lists keep to their limit.
|
||||
fn checked(what: &str, p: Value) -> Result<(), TestCaseError> {
|
||||
let r = catch_quiet(|| no_panic(vectorcraft_engine::Session::new().execute("script.check", &p)))
|
||||
.map_err(|e| TestCaseError::fail(format!("{what}: script.check panicked: {e}")))?;
|
||||
if let Ok(r) = r {
|
||||
for list in [&r["targets"], &r["directives"], &r["includes"], &r["problems"], &r["dom"]["members"], &r["menuCommands"]["strings"]] {
|
||||
prop_assert!(list.as_array().is_some_and(|l| l.len() <= 1000), "{}: {}", what, list);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sample the properties mutate reaches every part of the check.
|
||||
#[test]
|
||||
fn the_script_sample_is_checked_whole() {
|
||||
let r = vectorcraft_engine::Session::new().execute("script.check", &json!({"data": SCRIPT_SAMPLE})).unwrap();
|
||||
assert_eq!(r["kind"], "script");
|
||||
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands", "dialogs", "hostOnly"]));
|
||||
assert_eq!(r["problems"], json!([]));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn script_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2_000), compiled in any::<bool>()) {
|
||||
let name = if compiled { "garbage.jsxbin" } else { "garbage.jsx" };
|
||||
checked("script garbage", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": name}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_scripts_never_panic(pieces in prop::collection::vec(prop::sample::select(SCRIPT_PIECES.to_vec()), 0..120)) {
|
||||
checked("generated script", json!({"data": pieces.concat()}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_scripts_never_panic(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, any::<u8>()), 0..12)) {
|
||||
let mut bytes = SCRIPT_SAMPLE.as_bytes().to_vec();
|
||||
for (at, b) in edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
bytes.truncate(cut);
|
||||
checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! #1604: a document opened by a path relative to the working directory finds its relatively
|
||||
//! linked images, as when opened by its absolute path. Its own test binary: it changes the
|
||||
//! process's working directory.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn a_relative_path_finds_relative_links() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-relative-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
std::fs::write(dir.join("ext.png"), &png).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image x="0" y="0" width="40" height="40" href="ext.png"/></svg>"#;
|
||||
std::fs::write(dir.join("linked.svg"), svg).unwrap();
|
||||
std::env::set_current_dir(&dir).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"path": "linked.svg"})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert_eq!(r["missingLinks"], json!([]), "{r}");
|
||||
}
|
||||
@@ -15,6 +15,11 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
# A plain fsync where a volume refuses the full flush (`sync_file`, atomic.rs). Already in
|
||||
# Cargo.lock (tempfile), so this adds no new crate.
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
rustix = { workspace = true, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
+264
-9
@@ -1,6 +1,13 @@
|
||||
//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
|
||||
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
|
||||
//! [`write_new_with`] creates a file the same way without ever replacing one.
|
||||
//!
|
||||
//! Where a folder refuses the temporary file or the rename (some network shares, a file another
|
||||
//! program holds open), the new content is first written and flushed elsewhere and only then copied
|
||||
//! into the file itself; if that copy fails, the temporary file stays and the error names it.
|
||||
//!
|
||||
//! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with
|
||||
//! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
@@ -13,18 +20,88 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Write `path` atomically with `fill`, which writes the content into the temporary file. When
|
||||
/// anything fails (`fill`, flushing, the rename) the temporary file is removed and `path` is left
|
||||
/// untouched.
|
||||
/// `fill` or flushing fails the temporary file is removed and `path` is left untouched. When the
|
||||
/// folder refuses the temporary file or the rename, the content is copied into `path` instead (see
|
||||
/// the module docs).
|
||||
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
write_atomic_stepping(path, fill, create_new, |tmp, target| std::fs::rename(tmp, target))
|
||||
}
|
||||
|
||||
fn create_new(path: &Path) -> io::Result<File> {
|
||||
OpenOptions::new().write(true).create_new(true).open(path)
|
||||
}
|
||||
|
||||
/// [`write_atomic_with`], with `create` making the temporary file beside the target and `rename`
|
||||
/// moving it over the target.
|
||||
fn write_atomic_stepping(
|
||||
path: &Path,
|
||||
fill: impl FnOnce(&mut File) -> io::Result<()>,
|
||||
create: impl FnOnce(&Path) -> io::Result<File>,
|
||||
rename: impl FnOnce(&Path, &Path) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let target = resolve_link(path)?;
|
||||
let tmp = temp_path(&target)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
let beside = temp_path(&target)?;
|
||||
// `refused`: why the folder takes no temporary file (it is in the system's folder then).
|
||||
let (tmp, mut f, refused) = match create(&beside) {
|
||||
Ok(f) => (beside, f, None),
|
||||
// A folder that isn't there, a full volume: nothing can be saved there.
|
||||
Err(e) if matches!(e.kind(), io::ErrorKind::NotFound | io::ErrorKind::StorageFull | io::ErrorKind::QuotaExceeded) => return Err(e),
|
||||
Err(e) => {
|
||||
let Some(tmp) = beside.file_name().map(|n| std::env::temp_dir().join(n)) else { return Err(e) };
|
||||
let Ok(f) = create_private(&tmp) else { return Err(e) };
|
||||
(tmp, f, Some(e))
|
||||
}
|
||||
};
|
||||
let filled = fill(&mut f).and_then(|()| sync_file(&f));
|
||||
drop(f);
|
||||
filled.and_then(|()| keep_permissions(&target, &tmp)).and_then(|()| std::fs::rename(&tmp, &target)).inspect_err(|_| {
|
||||
if let Err(e) = filled {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
return Err(e);
|
||||
}
|
||||
let refused = match refused {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
// Best effort: a volume without Unix permissions (SMB, FAT) refuses them, and has none
|
||||
// to keep.
|
||||
let _ = keep_permissions(&target, &tmp);
|
||||
let Err(e) = rename(&tmp, &target) else { return Ok(()) };
|
||||
e
|
||||
}
|
||||
};
|
||||
write_over(&tmp, &target, refused)
|
||||
}
|
||||
|
||||
/// The content of `tmp`, which is whole and flushed, copied into `target` itself, and `tmp`
|
||||
/// removed: for a folder that `refused` the atomic replacement. When `target` can't be opened for
|
||||
/// writing it is untouched: `tmp` is removed and `refused` returned. When the copy fails `target`
|
||||
/// is damaged: `tmp` stays and the error says where it is.
|
||||
fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
|
||||
let opened = File::open(tmp).and_then(|from| Ok((from, OpenOptions::new().write(true).create(true).truncate(false).open(target)?)));
|
||||
let Ok((mut from, mut to)) = opened else {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(refused);
|
||||
};
|
||||
let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to));
|
||||
drop((from, to));
|
||||
match copied {
|
||||
Ok(()) => {
|
||||
// Best effort: the file is saved, so the copy is no longer needed.
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new file for its owner only (Unix): the system's temporary folder is shared.
|
||||
fn create_private(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
/// Create `path` with the content `fill` writes, never replacing a file or a link of that name. The
|
||||
@@ -45,7 +122,7 @@ fn write_new_linking(
|
||||
) -> io::Result<()> {
|
||||
let tmp = temp_path(path)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
let filled = fill(&mut f).and_then(|()| sync_file(&f));
|
||||
drop(f);
|
||||
let placed = filled.and_then(|()| match link(&tmp, path) {
|
||||
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(&tmp, path),
|
||||
@@ -60,7 +137,7 @@ fn write_new_linking(
|
||||
/// removes it again.
|
||||
fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
|
||||
let mut to = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| to.sync_all());
|
||||
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| sync_file(&to));
|
||||
drop(to);
|
||||
if copied.is_err() {
|
||||
// Best effort: the part copied is all there is to clean up.
|
||||
@@ -69,6 +146,63 @@ fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
|
||||
copied
|
||||
}
|
||||
|
||||
/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
|
||||
/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares, #1620) this falls back
|
||||
/// to a plain `fsync`. Any other error, and a failed `fsync`, is returned as it is.
|
||||
pub fn sync_file(file: &File) -> io::Result<()> {
|
||||
match full_sync(file) {
|
||||
Err(e) if full_flush_unsupported(&e) => plain_fsync(file),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` is a volume refusing a full flush. Never off Apple platforms: there `sync_all` is
|
||||
/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` to `Uncategorized`.
|
||||
fn full_flush_unsupported(e: &io::Error) -> bool {
|
||||
cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP)
|
||||
}
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn plain_fsync(file: &File) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
PLAIN_FSYNCS.with(|c| c.set(c.get() + 1));
|
||||
if let Some(errno) = PLAIN_FSYNC_FAILS.with(std::cell::Cell::get) {
|
||||
return Err(io::Error::from_raw_os_error(errno));
|
||||
}
|
||||
}
|
||||
rustix::fs::fsync(file).map_err(io::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
fn plain_fsync(file: &File) -> io::Result<()> {
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
fn full_sync(file: &File) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
if REFUSE_FULL_SYNC.with(std::cell::Cell::get) {
|
||||
return Err(io::Error::from_raw_os_error(ENOTSUP));
|
||||
}
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
/// What macOS answers when a volume can't make a full flush (SMB shares).
|
||||
#[cfg(target_vendor = "apple")]
|
||||
const ENOTSUP: i32 = 45;
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
const ENOTSUP: i32 = 95;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: every full flush on this thread fails as on an SMB share mounted by macOS.
|
||||
static REFUSE_FULL_SYNC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: the plain `fsync` fallback fails too, with this OS error.
|
||||
static PLAIN_FSYNC_FAILS: std::cell::Cell<Option<i32>> = const { std::cell::Cell::new(None) };
|
||||
/// Tests: how many plain `fsync` fallbacks were made on this thread.
|
||||
static PLAIN_FSYNCS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A symbolic link keeps pointing at its file: write the file it names.
|
||||
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
@@ -137,6 +271,127 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save to an SMB share (#1620): macOS refuses the full flush there with `ENOTSUP`, so the
|
||||
/// file is flushed with a plain `fsync` and saved. When that fails too the save fails as before,
|
||||
/// the old file stays and no temporary file is left. Elsewhere `sync_all` is already a plain
|
||||
/// `fsync`: a refusal is an error like any other.
|
||||
#[test]
|
||||
fn a_volume_that_refuses_the_full_flush_still_saves() {
|
||||
let d = dir("no-full-sync");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let apple = cfg!(target_vendor = "apple");
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(true));
|
||||
let before = PLAIN_FSYNCS.with(std::cell::Cell::get);
|
||||
let saved = write_atomic(&path, b"new");
|
||||
let created = write_new_with(&d.join("new.otf"), |f| f.write_all(b"created"));
|
||||
let fsyncs = PLAIN_FSYNCS.with(std::cell::Cell::get) - before;
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(Some(5)));
|
||||
let failed = write_atomic(&path, b"lost");
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(None));
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(false));
|
||||
assert_eq!(saved.is_ok(), apple, "{saved:?}");
|
||||
assert_eq!(created.is_ok(), apple, "{created:?}");
|
||||
assert_eq!(fsyncs, if apple { 2 } else { 0 }, "each refused full flush falls back to fsync");
|
||||
assert!(failed.is_err(), "a failed fsync still fails the save");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), if apple { &b"new"[..] } else { b"original" });
|
||||
let mut left = leftovers(&d, "doc.vectorcraft");
|
||||
left.retain(|n| n != "new.otf");
|
||||
assert!(left.is_empty(), "no temporary file is left: {left:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn denied<T>() -> io::Result<T> {
|
||||
Err(io::Error::new(io::ErrorKind::PermissionDenied, "refused by the share"))
|
||||
}
|
||||
|
||||
/// A folder that refuses the rename over the file (#1620: some network shares): the new content,
|
||||
/// whole in the temporary file, is copied into the file itself. A new file is created.
|
||||
#[test]
|
||||
fn a_refused_rename_copies_the_content_into_the_file() {
|
||||
let d = dir("no-rename");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original, and longer").unwrap();
|
||||
write_atomic_stepping(&path, |f| f.write_all(b"new"), create_new, |_, _| denied()).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
write_atomic_stepping(&d.join("other.vectorcraft"), |f| f.write_all(b"other"), create_new, |_, _| denied()).unwrap();
|
||||
assert_eq!(std::fs::read(d.join("other.vectorcraft")).unwrap(), b"other");
|
||||
assert_eq!(leftovers(&d, "doc.vectorcraft"), ["other.vectorcraft"], "the temporary files are removed");
|
||||
// A failed write never gets as far as the file.
|
||||
let r = write_atomic_stepping(&path, |f| f.write_all(b"half").and(Err(io::Error::other("disk full"))), create_new, |_, _| denied());
|
||||
assert_eq!(r.unwrap_err().to_string(), "disk full");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A refused rename where the file can't be written either (here: a folder of that name): the
|
||||
/// rename's error, nothing changed and no temporary file left.
|
||||
#[test]
|
||||
fn a_refused_rename_over_a_file_that_cannot_be_written_changes_nothing() {
|
||||
let d = dir("no-rename-no-write");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let r = write_atomic_stepping(&path, |f| f.write_all(b"new"), create_new, |_, _| denied());
|
||||
assert_eq!(r.unwrap_err().to_string(), "refused by the share");
|
||||
assert!(path.is_dir() && leftovers(&d, "doc.vectorcraft").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A refused rename and a copy into the file that fails (here: its flush): the file is damaged,
|
||||
/// so the temporary file with the whole new content stays and the error names it.
|
||||
#[test]
|
||||
fn a_failed_copy_into_the_file_keeps_the_temporary_file() {
|
||||
let d = dir("no-rename-copy-fails");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let r = write_atomic_stepping(
|
||||
&path,
|
||||
|f| f.write_all(b"new"),
|
||||
create_new,
|
||||
|_, _| {
|
||||
// From here on flushing fails (also the plain `fsync` macOS falls back to).
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(true));
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(Some(5)));
|
||||
denied()
|
||||
},
|
||||
);
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(false));
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(None));
|
||||
let message = r.unwrap_err().to_string();
|
||||
let left = leftovers(&d, "doc.vectorcraft");
|
||||
let [tmp] = left.as_slice() else { panic!("one temporary file: {left:?}") };
|
||||
assert!(message.contains(tmp.as_str()), "{message}");
|
||||
assert_eq!(std::fs::read(d.join(tmp)).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A folder that takes no temporary file (#1620: a share that refuses hidden or new names) but
|
||||
/// lets the file be written: the content goes through the system's temporary folder, and no
|
||||
/// rename is tried. A folder that isn't there is still an error.
|
||||
#[test]
|
||||
fn a_folder_that_refuses_the_temporary_file_still_saves() {
|
||||
let d = dir("no-temp");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original, and longer").unwrap();
|
||||
let renamed = std::cell::Cell::new(false);
|
||||
let rename = |_: &Path, _: &Path| {
|
||||
renamed.set(true);
|
||||
Ok(())
|
||||
};
|
||||
write_atomic_stepping(&path, |f| f.write_all(b"new"), |_| denied(), rename).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert!(!renamed.get() && leftovers(&d, "doc.vectorcraft").is_empty());
|
||||
// The file can't be written either (a folder of that name): the folder's error.
|
||||
let folder = d.join("folder");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let r = write_atomic_stepping(&folder, |f| f.write_all(b"new"), |_| denied(), |_, _| Ok(()));
|
||||
assert_eq!(r.unwrap_err().to_string(), "refused by the share");
|
||||
let r = write_atomic_stepping(&path, |f| f.write_all(b"lost"), |_| Err(io::Error::from(io::ErrorKind::NotFound)), |_, _| Ok(()));
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::NotFound));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_new_never_replaces_what_is_there() {
|
||||
let d = dir("new");
|
||||
|
||||
@@ -45,7 +45,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use atomic::{write_atomic, write_atomic_with, write_new_with};
|
||||
pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
|
||||
use stream::{Gz, Indent, KeyScan, Out};
|
||||
|
||||
/// v4: large data after the JSON (v1 to v3 files still load).
|
||||
|
||||
+122
-10
@@ -1,12 +1,12 @@
|
||||
//! Pattern fills and the pattern-editing preview.
|
||||
//!
|
||||
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
|
||||
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
|
||||
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
|
||||
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
|
||||
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
|
||||
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
|
||||
//! "revision".
|
||||
//! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a
|
||||
//! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`,
|
||||
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
|
||||
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
|
||||
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
|
||||
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision".
|
||||
//!
|
||||
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||
@@ -28,10 +28,13 @@ use crate::{Frame, RenderOptions, Rendered, Renderer};
|
||||
/// Largest super-tile raster side (pixels).
|
||||
const MAX_TILE_PX: f64 = 2048.0;
|
||||
|
||||
/// What a tile is kept under: the pattern's name and the bits of its raster scale
|
||||
/// ([`raster_scale`]).
|
||||
type TileKey = (String, u64);
|
||||
|
||||
struct TileEntry {
|
||||
art: Vec<Arc<Node>>,
|
||||
params: (Rect, TileType, Overlap),
|
||||
scale: f64,
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
pixmap: Arc<Pixmap>,
|
||||
@@ -39,7 +42,7 @@ struct TileEntry {
|
||||
|
||||
struct PatternCache {
|
||||
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
|
||||
tiles: [FrameCache<String, TileEntry>; 4],
|
||||
tiles: [FrameCache<TileKey, TileEntry>; 4],
|
||||
renderer: Option<Box<Renderer>>,
|
||||
depth: u32,
|
||||
}
|
||||
@@ -155,11 +158,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let (pw, ph) = def.period();
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let key = (name.to_string(), scale.to_bits());
|
||||
let hit = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let t = tiles.tick();
|
||||
tiles.get(name, t).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
tiles.get(&key, t).filter(|e| e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some(h) => h,
|
||||
@@ -176,7 +180,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
||||
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
@@ -379,6 +383,114 @@ mod tests {
|
||||
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
|
||||
}
|
||||
|
||||
/// `doc_with_pattern(TileType::Grid)` with a 40×40 square on top of its rectangle, filled with
|
||||
/// the same pattern scaled by `s`, as Transform Pattern Tiles leaves it.
|
||||
fn doc_with_two_scales(s: f64) -> Document {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let square = shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0));
|
||||
let mut n = Node::path(d.alloc_id(), square, Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
|
||||
vectorcraft_doc::pattern::transform_pattern_paints(&mut n, Affine::scale(s));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `d` drawn by `r` at `scale`.
|
||||
fn frame(r: &mut Renderer, d: &Document, scale: f64) -> Rendered {
|
||||
let px = (200.0 * scale) as u32;
|
||||
r.render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
/// The tiles rasterized on this thread so far: the tile renderer counts a frame for each.
|
||||
fn tiles_rasterized() -> u64 {
|
||||
CACHE.with(|c| c.borrow().renderer.as_ref().map_or(0, |r| r.stamp))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_at_two_pattern_scales_keep_their_tiles_between_frames() {
|
||||
// At 100% the rectangle's tile is rasterized at scale 1, and the square's tile is
|
||||
// rasterized at 0.25, or at 2 when its tiles are scaled to 101%.
|
||||
for s in [0.25, 1.01] {
|
||||
let d = doc_with_two_scales(s);
|
||||
let mut r = Renderer::new();
|
||||
let start = tiles_rasterized();
|
||||
let first = frame(&mut r, &d, 1.0).pixels;
|
||||
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: a tile for each object");
|
||||
for _ in 0..3 {
|
||||
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
|
||||
}
|
||||
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: later frames draw both objects from their tiles");
|
||||
// After a frame at 200%, the next frame at 100% draws from the tiles made at 100%.
|
||||
frame(&mut r, &d, 2.0);
|
||||
let zoomed = tiles_rasterized();
|
||||
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
|
||||
assert_eq!(tiles_rasterized(), zoomed, "tiles scaled by {s}: back at 100%");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_tiles_draw_what_a_new_renderer_draws() {
|
||||
// A new renderer on a thread of its own starts with no tiles.
|
||||
let fresh = |d: &Document, zoom: f64| {
|
||||
let d = d.clone();
|
||||
std::thread::spawn(move || frame(&mut Renderer::new(), &d, zoom).pixels).join().unwrap()
|
||||
};
|
||||
let mut d = doc_with_two_scales(0.25);
|
||||
let mut r = Renderer::new();
|
||||
// Pixel (130, 130) is red in the square's tiles and clear in the rectangle's, (133, 133) is
|
||||
// clear in both, and (103, 103), outside the square, is red in the rectangle's.
|
||||
let first = frame(&mut r, &d, 1.0);
|
||||
assert!(is_red(first.pixel(130, 130)) && is_white(first.pixel(133, 133)) && is_red(first.pixel(103, 103)));
|
||||
for zoom in [1.0, 2.0, 1.0, 0.5, 2.0, 1.0] {
|
||||
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "zoom {zoom}");
|
||||
}
|
||||
// Change the tile type as Pattern Options does.
|
||||
d.patterns[0].tile_type = TileType::BrickByRow { offset: 0.5 };
|
||||
for zoom in [2.0, 1.0] {
|
||||
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "brick, zoom {zoom}");
|
||||
}
|
||||
// Replace the art as Done Editing Pattern does.
|
||||
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
|
||||
d.patterns[0].art = vec![Arc::new(Node::path(NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), blue))];
|
||||
for zoom in [1.0, 2.0] {
|
||||
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "new art, zoom {zoom}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_tiles_stay_within_the_budget() {
|
||||
// The rectangle's tile is 20 pixels a side per 100% of zoom, and the square's tile is 5
|
||||
// pixels a side per 100% of zoom. The budget holds the rectangle's tile at 400% and both
|
||||
// tiles at 200%; the tiles drawn from 100% to 400% need more.
|
||||
let d = doc_with_two_scales(0.25);
|
||||
let tile = |side: usize| size_of::<TileEntry>() + side * side * 4;
|
||||
let budget = tile(80) + tile(40) + tile(10);
|
||||
CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX));
|
||||
let kept = || {
|
||||
CACHE.with(|c| {
|
||||
let c = c.borrow();
|
||||
let tiles = &c.tiles[Ink::Display as usize];
|
||||
(tiles.len(), tiles.bytes())
|
||||
})
|
||||
};
|
||||
let mut r = Renderer::new();
|
||||
for zoom in [1.0, 2.0, 4.0] {
|
||||
frame(&mut r, &d, zoom);
|
||||
let drawn = tiles_rasterized();
|
||||
frame(&mut r, &d, zoom);
|
||||
assert_eq!(tiles_rasterized(), drawn, "zoom {zoom}: the next frame draws from the tiles just made");
|
||||
let (len, bytes) = kept();
|
||||
assert!(len >= 2 && bytes <= budget, "zoom {zoom}: {len} tiles, {bytes} bytes");
|
||||
}
|
||||
// Tiles were rasterized at five scales (0.25, 0.5, 1, 2 and 4); the cache dropped the least
|
||||
// recently used tiles first.
|
||||
assert!(kept().0 < 5, "{:?}", kept());
|
||||
let before = tiles_rasterized();
|
||||
frame(&mut r, &d, 1.0);
|
||||
assert!(tiles_rasterized() > before, "a tile of 100% was dropped");
|
||||
assert!(kept().1 <= budget, "{:?}", kept());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pattern_is_grey() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
|
||||
@@ -467,3 +467,39 @@ fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
|
||||
assert_eq!(supersample_factor(5000, 5000), None);
|
||||
assert_eq!(supersample_factor(40_000, 10), None);
|
||||
}
|
||||
|
||||
/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a
|
||||
/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing
|
||||
/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments).
|
||||
#[test]
|
||||
fn zero_length_subpaths_draw_dots_with_their_cap() {
|
||||
let dots = |cap: vectorcraft_doc::LineCap| {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut bp = BezPath::new();
|
||||
for at in [(30.0, 50.0), (70.0, 50.0)] {
|
||||
bp.move_to(at);
|
||||
bp.line_to(at);
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0));
|
||||
n.appearance.stroke_mut().unwrap().cap = cap;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
render(&d)
|
||||
};
|
||||
let (black, white) = ([0, 0, 0, 255], [255, 255, 255, 255]);
|
||||
let round = dots(vectorcraft_doc::LineCap::Round);
|
||||
for x in [30, 70] {
|
||||
assert_eq!(round.pixel(x, 50), black, "round dot at {x}");
|
||||
assert_eq!(round.pixel(x + 2, 52), black, "inside the round dot at {x}");
|
||||
assert_eq!(round.pixel(x + 4, 54), white, "the round dot's corner at {x}");
|
||||
}
|
||||
let square = dots(vectorcraft_doc::LineCap::Square);
|
||||
for x in [30, 70] {
|
||||
assert_eq!(square.pixel(x, 50), black, "square dot at {x}");
|
||||
assert_eq!(square.pixel(x + 4, 54), black, "the square dot's corner at {x}");
|
||||
assert_eq!(square.pixel(x + 6, 50), white, "past the square dot at {x}");
|
||||
}
|
||||
let butt = dots(vectorcraft_doc::LineCap::Butt);
|
||||
assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
description = "Static checker for scripts written for the reference app: what a script uses, without running it"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,179 @@
|
||||
//! The names the checker looks for, each list with the public page it comes from, and the need each
|
||||
//! feature sets. Only names are taken from these pages; no description or example is copied.
|
||||
|
||||
use crate::Need;
|
||||
|
||||
// The preprocessor directives, written `#name` or `//@name`:
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/
|
||||
pub(crate) const DIRECTIVES: &[&str] = &["include", "includepath", "script", "strict", "target", "targetengine"];
|
||||
|
||||
// The `$` object's environment and file functions:
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/
|
||||
pub(crate) const DOLLAR: &str = "$";
|
||||
pub(crate) const DOLLAR_ENV: &[&str] = &["getenv", "setenv"];
|
||||
pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile";
|
||||
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/
|
||||
pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"];
|
||||
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/
|
||||
pub(crate) const LOCALIZE: &str = "localize";
|
||||
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/specifying-measurement-values/
|
||||
pub(crate) const UNIT_VALUE: &str = "UnitValue";
|
||||
|
||||
// File and Folder objects, their functions and their open modes:
|
||||
// https://extendscript.docsforadobe.dev/file-system-access/file-object/
|
||||
// https://extendscript.docsforadobe.dev/file-system-access/folder-object/
|
||||
pub(crate) const FILE: &str = "File";
|
||||
pub(crate) const FOLDER: &str = "Folder";
|
||||
pub(crate) const FILE_READS: &[&str] = &["read", "readch", "readln"];
|
||||
pub(crate) const FOLDER_READS: &[&str] = &["getFiles"];
|
||||
pub(crate) const FILE_WRITES: &[&str] = &["write", "writeln", "remove", "rename", "copy", "createAlias", "create"];
|
||||
pub(crate) const FILE_DIALOGS: &[&str] = &["openDialog", "saveDialog"];
|
||||
pub(crate) const FOLDER_DIALOGS: &[&str] = &["selectDialog"];
|
||||
pub(crate) const INSTANCE_DIALOGS: &[&str] = &["openDlg", "saveDlg", "selectDlg"];
|
||||
pub(crate) const FOLDER_PLACES: &[&str] =
|
||||
&["appData", "appPackage", "commonFiles", "current", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"];
|
||||
pub(crate) const EXECUTE: &str = "execute";
|
||||
pub(crate) const OPEN: &str = "open";
|
||||
/// The open modes that write: write, append and edit.
|
||||
pub(crate) const WRITE_MODES: &[&str] = &["w", "a", "e"];
|
||||
|
||||
// Windows and their types, and drawing:
|
||||
// https://extendscript.docsforadobe.dev/user-interface-tools/window-object/
|
||||
// https://extendscript.docsforadobe.dev/user-interface-tools/resource-specifications/
|
||||
// https://extendscript.docsforadobe.dev/user-interface-tools/graphic-customization-objects/
|
||||
pub(crate) const WINDOW: &str = "Window";
|
||||
pub(crate) const ON_DRAW: &str = "onDraw";
|
||||
pub(crate) const DRAWING: &[&str] = &["newPen", "newBrush"];
|
||||
|
||||
// https://extendscript.docsforadobe.dev/interapplication-communication/bridgetalk-message-object/
|
||||
pub(crate) const BRIDGE_TALK: &str = "BridgeTalk";
|
||||
// https://extendscript.docsforadobe.dev/external-communication/socket-object/
|
||||
pub(crate) const SOCKET: &str = "Socket";
|
||||
// https://extendscript.docsforadobe.dev/integrating-external-libraries/externalobject-object/
|
||||
pub(crate) const EXTERNAL_OBJECT: &str = "ExternalObject";
|
||||
// https://extendscript.docsforadobe.dev/scripting-xmp/xmpscript-object-reference/
|
||||
pub(crate) const XMP: &[&str] = &["XMPMeta", "XMPFile", "XMPUtils", "XMPConst", "XMPDateTime"];
|
||||
// https://extendscript.docsforadobe.dev/integrating-xml/the-xml-object/
|
||||
pub(crate) const XML: &[&str] = &["XML", "XMLList"];
|
||||
// The operators a script can define for its own classes:
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/operator-overloading/
|
||||
pub(crate) const OPERATORS: &[&str] = &["+", "-", "*", "/", "%", "^", "<", "<=", "==", "<<", ">>", ">>>", "&", "|", "===", "~"];
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/extendscript-reflection-interface/
|
||||
pub(crate) const REFLECT: &str = "reflect";
|
||||
|
||||
// The roots of the document model: https://ai-scripting.docsforadobe.dev/jsobjref/Application/
|
||||
pub(crate) const DOM_ROOTS: [&str; 4] = ["app", "activeDocument", "documents", "selection"];
|
||||
|
||||
// Application methods that page doesn't list, named in public forum posts:
|
||||
// https://community.adobe.com/t5/illustrator-discussions/changes-in-cs6/td-p/4173362 (executeMenuCommand,
|
||||
// doScript, loadAction, unloadAction) and
|
||||
// https://community.adobe.com/t5/illustrator-discussions/what-s-new-in-illustrator-scripting-cc2020/m-p/10773439
|
||||
// (selectTool).
|
||||
pub(crate) const MENU_COMMAND: &str = "executeMenuCommand";
|
||||
pub(crate) const ACTIONS: &[&str] = &["doScript", "loadAction", "unloadAction"];
|
||||
pub(crate) const SELECT_TOOL: &str = "selectTool";
|
||||
/// The application methods above, which the report lists as features, not as member chains.
|
||||
pub(crate) const APP_METHODS: &[&str] = &["executeMenuCommand", "doScript", "loadAction", "unloadAction", "selectTool"];
|
||||
/// Text an action set written out at run time holds.
|
||||
pub(crate) const ACTION_TEXT: &str = "/actionCount";
|
||||
|
||||
/// Constructors of the language (ECMA-262 3rd edition, section 15) and of E4X (ECMA-357 2nd edition,
|
||||
/// section 13), and the platform's own: `new` of these is no document-model type.
|
||||
pub(crate) const CONSTRUCTORS: &[&str] = &[
|
||||
"Object",
|
||||
"Function",
|
||||
"Array",
|
||||
"String",
|
||||
"Boolean",
|
||||
"Number",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"Error",
|
||||
"EvalError",
|
||||
"RangeError",
|
||||
"ReferenceError",
|
||||
"SyntaxError",
|
||||
"TypeError",
|
||||
"URIError",
|
||||
"Namespace",
|
||||
"QName",
|
||||
"XML",
|
||||
"XMLList",
|
||||
"File",
|
||||
"Folder",
|
||||
"Window",
|
||||
"Socket",
|
||||
"BridgeTalk",
|
||||
"ExternalObject",
|
||||
"UnitValue",
|
||||
"XMPMeta",
|
||||
"XMPFile",
|
||||
"XMPDateTime",
|
||||
];
|
||||
|
||||
/// Every feature id a report can list, in the order the report lists them.
|
||||
pub const FEATURES: &[&str] = &[
|
||||
"actions",
|
||||
"bridgeTalk",
|
||||
"directive.include",
|
||||
"directive.other",
|
||||
"directive.target",
|
||||
"directive.targetengine",
|
||||
"dollar",
|
||||
"dollar.env",
|
||||
"dollar.evalFile",
|
||||
"dynamicCode",
|
||||
"e4x",
|
||||
"externalObject",
|
||||
"file.dialog",
|
||||
"file.execute",
|
||||
"file.objects",
|
||||
"file.places",
|
||||
"file.read",
|
||||
"file.write",
|
||||
"hostCheck",
|
||||
"localize",
|
||||
"menuCommand",
|
||||
"notify",
|
||||
"operatorOverloading",
|
||||
"preferences",
|
||||
"reflection",
|
||||
"scriptui.dialog",
|
||||
"scriptui.drawing",
|
||||
"scriptui.palette",
|
||||
"scriptui.window",
|
||||
"selectTool",
|
||||
"socket",
|
||||
"unitValue",
|
||||
"xml",
|
||||
"xmp",
|
||||
];
|
||||
|
||||
/// The need feature `id` sets.
|
||||
pub(crate) fn need(id: &str) -> Option<Need> {
|
||||
Some(match id {
|
||||
"directive.target" | "directive.include" | "directive.other" | "dollar" | "dynamicCode" | "notify" | "localize" | "unitValue"
|
||||
| "preferences" | "hostCheck" => Need::Language,
|
||||
"dollar.env" | "dollar.evalFile" | "file.objects" | "file.read" | "file.write" | "file.dialog" | "file.places" | "file.execute" => {
|
||||
Need::Files
|
||||
}
|
||||
"menuCommand" => Need::MenuCommands,
|
||||
"scriptui.dialog" | "scriptui.window" | "scriptui.drawing" => Need::Dialogs,
|
||||
"directive.targetengine"
|
||||
| "scriptui.palette"
|
||||
| "bridgeTalk"
|
||||
| "socket"
|
||||
| "externalObject"
|
||||
| "xmp"
|
||||
| "xml"
|
||||
| "e4x"
|
||||
| "operatorOverloading"
|
||||
| "reflection"
|
||||
| "actions"
|
||||
| "selectTool" => Need::HostOnly,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Preprocessor lines (`#target acme-12`, `//@include "lib.jsxinc"`): their names and values, the
|
||||
//! parts of a target's application specifier, and the folders an `#includepath` line names.
|
||||
|
||||
use crate::catalog;
|
||||
|
||||
/// A preprocessor line.
|
||||
pub(crate) struct Line<'s> {
|
||||
/// `#` or `//@`.
|
||||
pub form: &'static str,
|
||||
/// The name as written.
|
||||
pub name: &'s str,
|
||||
/// The documented directive the name is (compared without case), if it is one.
|
||||
pub known: Option<&'static str>,
|
||||
/// Its value: the text after the name, without quotes around it, or else without a trailing
|
||||
/// `//` comment and `;`.
|
||||
pub value: &'s str,
|
||||
}
|
||||
|
||||
/// The line `text`, which starts with `#` or `//@`.
|
||||
pub(crate) fn parse(text: &str) -> Line<'_> {
|
||||
let (form, rest) = match text.strip_prefix("//@") {
|
||||
Some(rest) => ("//@", rest),
|
||||
None => ("#", text.strip_prefix('#').unwrap_or(text)),
|
||||
};
|
||||
let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len());
|
||||
let (name, rest) = (rest.get(..end).unwrap_or(rest), rest.get(end..).unwrap_or_default());
|
||||
let known = catalog::DIRECTIVES.iter().copied().find(|d| d.eq_ignore_ascii_case(name));
|
||||
Line { form, name, known, value: value(rest) }
|
||||
}
|
||||
|
||||
fn value(rest: &str) -> &str {
|
||||
let v = rest.trim();
|
||||
if let Some(quote) = v.chars().next().filter(|c| matches!(c, '"' | '\'')) {
|
||||
let inner = v.get(1..).unwrap_or_default();
|
||||
return inner.find(quote).and_then(|i| inner.get(..i)).unwrap_or(inner);
|
||||
}
|
||||
let v = v.find("//").and_then(|i| v.get(..i)).unwrap_or(v);
|
||||
v.trim_end().trim_end_matches(';').trim_end()
|
||||
}
|
||||
|
||||
/// The parts of an application specifier, `name_instance-version-locale`, every part after the name
|
||||
/// optional, as
|
||||
/// https://extendscript.docsforadobe.dev/interapplication-communication/application-and-namespace-specifiers/
|
||||
/// lays it out.
|
||||
pub(crate) struct Specifier<'s> {
|
||||
pub app: &'s str,
|
||||
pub instance: Option<&'s str>,
|
||||
pub version: Option<&'s str>,
|
||||
pub locale: Option<&'s str>,
|
||||
}
|
||||
|
||||
pub(crate) fn target(value: &str) -> Specifier<'_> {
|
||||
let (head, tail) = match value.split_once('-') {
|
||||
Some((head, tail)) => (head, Some(tail)),
|
||||
None => (value, None),
|
||||
};
|
||||
let (app, instance) = match head.split_once('_') {
|
||||
Some((app, instance)) => (app, Some(instance)),
|
||||
None => (head, None),
|
||||
};
|
||||
let (version, locale) = match tail.map(|t| t.split_once('-').map_or((t, None), |(v, l)| (v, Some(l)))) {
|
||||
Some((version, locale)) => (Some(version), locale),
|
||||
None => (None, None),
|
||||
};
|
||||
fn part(s: Option<&str>) -> Option<&str> {
|
||||
s.filter(|s| !s.is_empty())
|
||||
}
|
||||
Specifier { app, instance: part(instance), version: part(version), locale: part(locale) }
|
||||
}
|
||||
|
||||
/// The most folders an `#includepath` line names that are kept.
|
||||
const MAX_PATHS: usize = 64;
|
||||
|
||||
/// The longest `#include` name or `#includepath` folder followed, in bytes.
|
||||
pub(crate) const MAX_PATH: usize = 4096;
|
||||
|
||||
/// The folders an `#includepath` value names, separated by `;`: the first [`MAX_PATHS`] of those
|
||||
/// no longer than [`MAX_PATH`].
|
||||
pub(crate) fn include_paths(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(';')
|
||||
.map(|p| p.trim().trim_matches(['"', '\'']))
|
||||
.filter(|p| !p.is_empty() && p.len() <= MAX_PATH)
|
||||
.take(MAX_PATHS)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Following `#include` lines: the file each one names, found inside the script's folder and read
|
||||
//! through the caller's [`Loader`].
|
||||
//!
|
||||
//! The preprocessor page (https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)
|
||||
//! says that a name is looked for as given and then in each `#includepath` folder in turn, that a
|
||||
//! name starting with `/` is absolute, and that `JSINCLUDE` is a fallback; it doesn't say which
|
||||
//! folder a relative name starts from. The checker takes a name, and an `#includepath` folder, from
|
||||
//! the folder of the file the line is in. It never follows an absolute name, a name or folder that
|
||||
//! leads out of the script's folder (`..`), a name or folder longer than [`MAX_PATH`] bytes, or
|
||||
//! `JSINCLUDE`. Each file is read once. A queue holds the files still to follow, in the order they
|
||||
//! were read, and nothing recurses: each file is reached first at its smallest depth. At most
|
||||
//! [`crate::Limits::lookups`] paths are looked up. Once every file is read, an
|
||||
//! `#include` line is a cycle when the file it names includes the file the line is in, directly or
|
||||
//! through other files.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::analyze::{self, IncludeRef, Known};
|
||||
use crate::directives::MAX_PATH;
|
||||
use crate::report::{Acc, IncludeStatus};
|
||||
|
||||
/// An included file that was read and is checked: its path inside the script's folder, its text
|
||||
/// (and whether it held bytes that aren't UTF-8), and the `#includepath` folders in force at its
|
||||
/// start.
|
||||
pub(crate) struct ReadFile {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
pub invalid: bool,
|
||||
pub paths: Rc<[String]>,
|
||||
}
|
||||
|
||||
/// What a [`Loader`] found at a path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Loaded {
|
||||
/// The file's first bytes (at most the `max` asked for).
|
||||
Bytes(Vec<u8>),
|
||||
/// No file there, or it can't be read.
|
||||
Missing,
|
||||
/// A file the caller may not read (outside the folders automation may read).
|
||||
Refused,
|
||||
}
|
||||
|
||||
/// Where the files a script includes come from: the folder the script is in.
|
||||
pub trait Loader {
|
||||
/// The first `max` bytes of the file at `path`, a path inside the script's folder with `/`
|
||||
/// between its names (`lib/util.jsxinc`).
|
||||
fn load(&mut self, path: &str, max: usize) -> Loaded;
|
||||
}
|
||||
|
||||
/// Follow the script's `includes`: `script` is its file name and `bytes` its size. Each file read
|
||||
/// is scanned here for its own includes and for what its variables hold, with its tallies left
|
||||
/// out; it starts with what `known` holds, which then gains what the file adds → the files to
|
||||
/// check, in the order they were read.
|
||||
pub(crate) fn follow(
|
||||
acc: &mut Acc,
|
||||
includes: Vec<IncludeRef>,
|
||||
loader: &mut dyn Loader,
|
||||
script: &str,
|
||||
bytes: usize,
|
||||
known: &mut Known,
|
||||
) -> Vec<ReadFile> {
|
||||
/// The `#include` lines of a file that was read, still to follow.
|
||||
struct Work {
|
||||
/// The file the lines are in: its index in `files` (0: the script).
|
||||
file: usize,
|
||||
includes: Vec<IncludeRef>,
|
||||
depth: usize,
|
||||
}
|
||||
/// An `#include` line as the report lists it.
|
||||
struct Row {
|
||||
value: String,
|
||||
line: usize,
|
||||
/// The file the line is in.
|
||||
from: usize,
|
||||
status: IncludeStatus,
|
||||
path: Option<String>,
|
||||
/// The file it names, when that file was read.
|
||||
to: Option<usize>,
|
||||
}
|
||||
let limits = acc.limits;
|
||||
// The files read, by their path inside the script's folder; the script first.
|
||||
let mut files: Vec<String> = vec![script.to_string()];
|
||||
// What became of each path found, and the file read there: a file is read once.
|
||||
let mut read: BTreeMap<String, (IncludeStatus, Option<usize>)> = BTreeMap::from([(script.to_string(), (IncludeStatus::Checked, Some(0)))]);
|
||||
// The files still to follow, in the order they were read: each file is reached first at its
|
||||
// smallest depth.
|
||||
let mut work = VecDeque::from([Work { file: 0, includes, depth: 0 }]);
|
||||
let mut rows: Vec<Row> = vec![];
|
||||
// Which file includes which.
|
||||
let mut edges: BTreeSet<(usize, usize)> = BTreeSet::new();
|
||||
// The bytes read and the included files read, those not checked too, and the paths looked up.
|
||||
let (mut total, mut loaded, mut lookups) = (bytes, 0usize, 0usize);
|
||||
// The files to check.
|
||||
let mut checked: Vec<ReadFile> = vec![];
|
||||
while let Some(w) = work.pop_front() {
|
||||
let folder = if w.file == 0 { String::new() } else { files.get(w.file).map_or("", |f| parent(f)).to_string() };
|
||||
for inc in w.includes {
|
||||
acc.steps = acc.steps.saturating_add(1);
|
||||
let mut status = IncludeStatus::NotFollowed;
|
||||
let (mut found, mut to): (Option<String>, Option<usize>) = (None, None);
|
||||
let mut refused = false;
|
||||
for path in candidates(&folder, &inc.value, &inc.paths) {
|
||||
acc.steps = acc.steps.saturating_add(1);
|
||||
if let Some(&(before, file)) = read.get(&path) {
|
||||
(status, found, to) = (before, Some(path), file);
|
||||
break;
|
||||
}
|
||||
if w.depth + 1 > limits.include_depth || loaded >= limits.includes || total >= limits.total_bytes || lookups >= limits.lookups {
|
||||
(status, found) = (IncludeStatus::Limit, None);
|
||||
break;
|
||||
}
|
||||
lookups += 1;
|
||||
status = IncludeStatus::Missing;
|
||||
// One byte more than a file or the rest of the total may take shows that it is too large.
|
||||
let max = limits.file_bytes.min(limits.total_bytes.saturating_sub(total)).saturating_add(1);
|
||||
let b = match loader.load(&path, max) {
|
||||
Loaded::Bytes(b) => b,
|
||||
Loaded::Missing => continue,
|
||||
Loaded::Refused => {
|
||||
refused = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Every file read counts, also one that is too large, compiled or not text.
|
||||
loaded += 1;
|
||||
total = total.saturating_add(b.len());
|
||||
acc.steps = acc.steps.saturating_add(b.len());
|
||||
status = if b.len() > limits.file_bytes || total > limits.total_bytes {
|
||||
IncludeStatus::Limit
|
||||
} else if crate::is_compiled(&path, &b) || !crate::is_text(&b) {
|
||||
IncludeStatus::Unreadable
|
||||
} else {
|
||||
IncludeStatus::Checked
|
||||
};
|
||||
if status != IncludeStatus::Checked {
|
||||
read.insert(path.clone(), (status, None));
|
||||
found = Some(path);
|
||||
break;
|
||||
}
|
||||
files.push(path.clone());
|
||||
let file = files.len() - 1;
|
||||
read.insert(path.clone(), (status, Some(file)));
|
||||
let (text, invalid) = crate::decode(&b);
|
||||
let mut tallies = Acc::new(limits);
|
||||
let scan = analyze::scan(&text, Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
known.add(scan.known, limits.entries);
|
||||
checked.push(ReadFile { path: path.clone(), text: text.into_owned(), invalid, paths: Rc::clone(&inc.paths) });
|
||||
work.push_back(Work { file, includes: scan.includes, depth: w.depth + 1 });
|
||||
(found, to) = (Some(path), Some(file));
|
||||
break;
|
||||
}
|
||||
if status == IncludeStatus::Missing && refused {
|
||||
status = IncludeStatus::NotFollowed;
|
||||
}
|
||||
if let Some(to) = to {
|
||||
edges.insert((w.file, to));
|
||||
}
|
||||
// One row more than the report keeps marks it truncated.
|
||||
if rows.len() <= limits.entries {
|
||||
rows.push(Row { value: acc.echo(&inc.value), line: inc.line, from: w.file, status, path: found, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
let reach = reachable(files.len(), &edges, acc);
|
||||
for r in rows {
|
||||
let cycle = r.to.and_then(|to| reach.get(to)).and_then(|reached| reached.get(r.from)).copied().unwrap_or(false);
|
||||
let status = if cycle { IncludeStatus::Cycle } else { r.status };
|
||||
let file = files.get(r.from).filter(|_| r.from > 0).map(String::as_str);
|
||||
acc.include(&r.value, r.line, file, status, r.path.as_deref());
|
||||
}
|
||||
checked
|
||||
}
|
||||
|
||||
/// For each of `n` files, the files it includes directly or through other files (itself too),
|
||||
/// with `edges` giving the files each one includes.
|
||||
fn reachable(n: usize, edges: &BTreeSet<(usize, usize)>, acc: &mut Acc) -> Vec<Vec<bool>> {
|
||||
let mut next: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
for &(from, to) in edges {
|
||||
if let Some(v) = next.get_mut(from) {
|
||||
v.push(to);
|
||||
}
|
||||
}
|
||||
(0..n)
|
||||
.map(|start| {
|
||||
let mut seen = vec![false; n];
|
||||
let mut stack = vec![start];
|
||||
while let Some(f) = stack.pop() {
|
||||
acc.steps = acc.steps.saturating_add(1);
|
||||
match seen.get_mut(f) {
|
||||
Some(s) if !*s => *s = true,
|
||||
_ => continue,
|
||||
}
|
||||
stack.extend(next.get(f).into_iter().flatten().copied());
|
||||
}
|
||||
seen
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List the script's `includes` as not read.
|
||||
pub(crate) fn not_read(acc: &mut Acc, includes: Vec<IncludeRef>) {
|
||||
for inc in includes {
|
||||
acc.include(&inc.value, inc.line, None, IncludeStatus::NotRead, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder part of `path` (`lib/a.jsxinc` → `lib`, `a.jsxinc` → ``).
|
||||
fn parent(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or("", |(folder, _)| folder)
|
||||
}
|
||||
|
||||
/// A name the checker doesn't follow: absolute (`/x`, `\x`, `C:x`, `~/x`), naming an environment
|
||||
/// variable (`%APPDATA%`, `$HOME`), or longer than [`MAX_PATH`].
|
||||
fn unfollowed(name: &str) -> bool {
|
||||
name.starts_with(['/', '\\', '~', '$']) || name.contains([':', '%']) || name.len() > MAX_PATH
|
||||
}
|
||||
|
||||
/// `rel` taken from `folder`, both inside the script's folder, as a path from that folder (empty
|
||||
/// for the folder itself): none when it leads out of it.
|
||||
fn walk(folder: &str, rel: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = folder.split('/').filter(|s| !s.is_empty()).collect();
|
||||
for part in rel.split(['/', '\\']) {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
p => parts.push(p),
|
||||
}
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
/// The file `rel` names from `folder`, both inside the script's folder: none when it leads out of
|
||||
/// it, or names the folder itself.
|
||||
fn join(folder: &str, rel: &str) -> Option<String> {
|
||||
walk(folder, rel).filter(|path| !path.is_empty())
|
||||
}
|
||||
|
||||
/// The paths an `#include` of `name` may mean, from `folder`, in the order they are tried and each
|
||||
/// once: the name itself, then the name in each `#includepath` folder. Each is worked out when it
|
||||
/// is tried. Names and folders the checker doesn't follow, and names or folders that lead out of
|
||||
/// the script's folder, give none.
|
||||
fn candidates<'a>(folder: &'a str, name: &'a str, paths: &'a [String]) -> impl Iterator<Item = String> + 'a {
|
||||
let name = Some(name).filter(|n| !unfollowed(n));
|
||||
let first = name.and_then(|n| join(folder, n));
|
||||
let inside = name
|
||||
.into_iter()
|
||||
.flat_map(move |n| paths.iter().filter(|p| !unfollowed(p)).filter_map(move |p| walk(folder, p)).filter_map(move |p| join(&p, n)));
|
||||
let mut tried: Vec<String> = vec![];
|
||||
first.into_iter().chain(inside).filter(move |p| {
|
||||
let new = !tried.contains(p);
|
||||
if new {
|
||||
tried.push(p.clone());
|
||||
}
|
||||
new
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tried(folder: &str, name: &str, paths: &[String]) -> Vec<String> {
|
||||
candidates(folder, name, paths).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_taken_from_the_including_files_folder() {
|
||||
assert_eq!(tried("", "a.jsxinc", &[]), ["a.jsxinc"]);
|
||||
assert_eq!(tried("lib", "b.jsxinc", &[]), ["lib/b.jsxinc"]);
|
||||
assert_eq!(tried("lib/deep", "../c.jsxinc", &[]), ["lib/c.jsxinc"]);
|
||||
assert_eq!(tried("lib", "./x\\y.jsxinc", &[]), ["lib/x/y.jsxinc"]);
|
||||
// Then each include path, inside the folder.
|
||||
let paths = ["shared".to_string(), "/abs".to_string(), "../out".to_string(), "shared".to_string()];
|
||||
assert_eq!(tried("", "u.jsxinc", &paths), ["u.jsxinc", "shared/u.jsxinc"]);
|
||||
// An include path may lead back to the script's own folder.
|
||||
assert_eq!(tried("lib", "shared.jsxinc", &["..".to_string()]), ["lib/shared.jsxinc", "shared.jsxinc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_names_and_names_leading_out_are_not_followed() {
|
||||
for name in
|
||||
["/etc/x.jsx", "\\\\server\\x.jsx", "C:/x.jsx", "~/x.jsx", "%APPDATA%/x.jsx", "$HOME/x.jsx", "../x.jsx", "a/../../x.jsx", ".", "a/.."]
|
||||
{
|
||||
assert!(tried("", name, &[]).is_empty(), "{name}");
|
||||
}
|
||||
assert!(tried("lib", "../../x.jsx", &[]).is_empty());
|
||||
// Names and folders longer than a path can be.
|
||||
let long = "d".repeat(MAX_PATH + 1);
|
||||
assert!(tried("", &long, &[]).is_empty());
|
||||
assert_eq!(tried("", "a.jsxinc", &[long]), ["a.jsxinc"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
//! The lexer: script text into tokens that cover it exactly once, blanks, line breaks and comments
|
||||
//! included. It reads ECMAScript 3 with E4X XML literals, the preprocessor lines (`#name …` at the
|
||||
//! start of a line, and `//@name …` comments there that name a directive), and the later syntax a
|
||||
//! script may hold (template literals, `=>`, `?.`), which the analysis reports. An XML literal's
|
||||
//! markup gives tokens of its own, and the code in its braces gives the same tokens as code anywhere
|
||||
//! else. Each step moves forward through the text, and nothing recurses: an XML literal's elements
|
||||
//! are counted, and the open parentheses, the open braces, the `?` of conditionals at each bracket
|
||||
//! level and the levels of XML literals and their code are kept on stacks capped at the nesting
|
||||
//! limit.
|
||||
|
||||
use crate::catalog;
|
||||
use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE};
|
||||
|
||||
/// What a token is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum T {
|
||||
/// Blanks: spaces, tabs and other white space.
|
||||
Space,
|
||||
/// One line break: LF, CR LF, CR, U+2028 or U+2029.
|
||||
Newline,
|
||||
/// A `//` or `/* */` comment.
|
||||
Comment,
|
||||
/// A preprocessor line: `#name …` at the start of a line, or a `//@name …` comment there that
|
||||
/// names a directive.
|
||||
Directive,
|
||||
Ident,
|
||||
Number,
|
||||
/// A `'…'` or `"…"` string literal.
|
||||
Str,
|
||||
/// A `` `…` `` template literal.
|
||||
Template,
|
||||
RegExp,
|
||||
/// The start of an E4X XML literal (`<a b="1">…</a>`, `<>…</>`, `<!-- … -->`): its markup up to
|
||||
/// its end, or up to and with the `{` of the code in it.
|
||||
Xml,
|
||||
/// More of an XML literal's markup: from the `}` that ends the code in it up to the literal's
|
||||
/// end or the next `{`.
|
||||
XmlMore,
|
||||
Punct,
|
||||
/// A character no token starts with, or `#` and a name away from the start of a line.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A token: what it is, its text and the line it starts on (from 1), with the problem it has.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Token<'s> {
|
||||
pub kind: T,
|
||||
pub text: &'s str,
|
||||
pub line: usize,
|
||||
pub problem: Option<&'static str>,
|
||||
/// A statement may start at the token (a `function` there is a declaration).
|
||||
pub stmt: bool,
|
||||
/// An expression may start at the token (a `/` there starts a regular expression).
|
||||
pub expr: bool,
|
||||
/// The byte offset in the text where the token starts.
|
||||
pub start: usize,
|
||||
}
|
||||
|
||||
/// The kinds of line break the text has used.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct Breaks {
|
||||
pub lf: bool,
|
||||
pub crlf: bool,
|
||||
pub cr: bool,
|
||||
}
|
||||
|
||||
/// Punctuators, longest first: ECMAScript 3's, E4X's (`..`, `.@`, `::`, `@`) and later ones (`=>`,
|
||||
/// `...`, `?.`, `??`, `**` and the assignments built on them), which the analysis reports.
|
||||
const PUNCTS: &[&str] = &[
|
||||
">>>=", "===", "!==", ">>>", "<<=", ">>=", "**=", "...", "&&=", "||=", "??=", "==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=",
|
||||
"/=", "%=", "&=", "|=", "^=", "<<", ">>", "=>", "**", "?.", "??", "::", "..", ".@", "{", "}", "(", ")", "[", "]", ";", ",", "<", ">", "+", "-",
|
||||
"*", "/", "%", "&", "|", "^", "!", "~", "?", ":", "=", ".", "@",
|
||||
];
|
||||
|
||||
pub(crate) struct Lexer<'s> {
|
||||
src: &'s str,
|
||||
pos: usize,
|
||||
line: usize,
|
||||
/// Only blanks since the last line break (or the start): a `#` here starts a preprocessor line.
|
||||
line_start: bool,
|
||||
/// An expression may start here: a `/` starts a regular expression and a `<` an XML literal.
|
||||
expr: bool,
|
||||
/// A statement may start here: a `{` here opens a block. A `{` where only an expression may
|
||||
/// start opens an object literal.
|
||||
stmt: bool,
|
||||
/// The last significant token was `return`, `throw`, `break` or `continue`: a line break here
|
||||
/// ends the statement (ECMA-262 3rd edition, section 7.9.1).
|
||||
restricted: bool,
|
||||
/// The last significant token was `.`, `..`, `.@` or `::`: a name here is a property's, also when
|
||||
/// it is a keyword such as `return`.
|
||||
member: bool,
|
||||
/// The last significant token was `if`, `while`, `for`, `with` or the `each` of `for each`: a
|
||||
/// `(` here opens a condition.
|
||||
cond: bool,
|
||||
/// For each `(` still open, whether it opened a condition, up to `nesting` of them; `deeper`
|
||||
/// counts the ones past that.
|
||||
parens: Vec<bool>,
|
||||
deeper: usize,
|
||||
/// For each `{` still open, what it opened, up to `nesting` of them; `deeper_braces` counts the
|
||||
/// ones past that.
|
||||
braces: Vec<Brace>,
|
||||
deeper_braces: usize,
|
||||
/// `function` was read and its body's `{` is still to come: whether the function is an
|
||||
/// expression (it starts where no statement may).
|
||||
function: Option<bool>,
|
||||
/// For the text outside any bracket and each bracket still open, how many `?` of a conditional
|
||||
/// (`? :`) wait for their `:` there, up to `nesting` levels; `deeper_questions` counts the
|
||||
/// levels past that.
|
||||
questions: Vec<usize>,
|
||||
deeper_questions: usize,
|
||||
/// The XML literal being read, its innermost level last: its markup, the code in a `{…}` in
|
||||
/// it, an XML literal in that code, and so on. Empty outside an XML literal.
|
||||
xml: Vec<Level>,
|
||||
/// The line the outermost XML literal being read starts on.
|
||||
xml_line: usize,
|
||||
/// The most open parentheses kept, and the most levels in `xml`.
|
||||
nesting: usize,
|
||||
/// The line of the XML literal the text ended in, when it ended in one.
|
||||
pub unclosed_xml: Option<usize>,
|
||||
pub breaks: Breaks,
|
||||
/// One per character read (and per token): the work done.
|
||||
pub steps: usize,
|
||||
}
|
||||
|
||||
fn is_break(c: char) -> bool {
|
||||
matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
|
||||
}
|
||||
|
||||
fn is_blank(c: char) -> bool {
|
||||
!is_break(c) && (c.is_whitespace() || c == '\u{feff}')
|
||||
}
|
||||
|
||||
fn ident_start(c: char) -> bool {
|
||||
c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic())
|
||||
}
|
||||
|
||||
/// A character a name may hold after its first.
|
||||
pub(crate) fn ident_part(c: char) -> bool {
|
||||
ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric())
|
||||
}
|
||||
|
||||
/// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a
|
||||
/// processing instruction) or `{` (a computed name).
|
||||
fn xml_start(c: char) -> bool {
|
||||
c.is_ascii_alphabetic() || matches!(c, '_' | '>' | '!' | '?' | '{')
|
||||
}
|
||||
|
||||
/// Whether an expression may start after a significant token. After a `}`, the brace's kind decides
|
||||
/// it ([`Lexer::brace`]), after an XML literal's markup [`Lexer::markup_token`] sets it, and after a
|
||||
/// property's name, a keyword included ([`Lexer::member`]), none starts.
|
||||
pub(crate) fn expr_after(kind: T, text: &str) -> bool {
|
||||
match kind {
|
||||
T::Ident => matches!(text, "return" | "typeof" | "instanceof" | "in" | "new" | "delete" | "void" | "throw" | "case" | "do" | "else"),
|
||||
T::Number | T::Str | T::Template | T::RegExp => false,
|
||||
T::Punct => !matches!(text, ")" | "]" | "++" | "--"),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `rest`, the text after `//@`, starts with a directive's name.
|
||||
fn names_directive(rest: &str) -> bool {
|
||||
let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
|
||||
let name = rest.get(..end).unwrap_or_default();
|
||||
catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Where in its markup an XML literal is read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum At {
|
||||
/// The `<` of a tag, comment, CDATA section or processing instruction.
|
||||
Markup,
|
||||
/// A start tag (`end` false) or an end tag, after its `<`.
|
||||
Tag { end: bool },
|
||||
/// An element's text.
|
||||
Text,
|
||||
}
|
||||
|
||||
/// A level of the XML literal being read.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Level {
|
||||
/// The markup of an XML literal, `open` of its elements open.
|
||||
Xml { open: usize, at: At },
|
||||
/// The code in braces, `braces` of them open.
|
||||
Code { braces: usize },
|
||||
}
|
||||
|
||||
/// What a `{` opened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Brace {
|
||||
/// A block, or a function declaration's body: a statement may start after its `}`.
|
||||
Block,
|
||||
/// An object literal: a value.
|
||||
Object,
|
||||
/// A function expression's body: statements inside, and a value after its `}`.
|
||||
Function,
|
||||
}
|
||||
|
||||
/// What reading a little more of an XML literal's markup led to.
|
||||
enum Step {
|
||||
/// The markup goes on.
|
||||
On,
|
||||
/// The literal ends: none of its elements is open.
|
||||
Up,
|
||||
/// Code in braces starts at the next character, a `{`.
|
||||
Code,
|
||||
/// The text ended first.
|
||||
End,
|
||||
}
|
||||
|
||||
impl<'s> Lexer<'s> {
|
||||
/// A lexer of `src` that keeps at most `nesting` open parentheses, open braces, bracket levels
|
||||
/// of conditionals and levels of an XML literal ([`crate::Limits::nesting`]).
|
||||
pub fn new(src: &'s str, nesting: usize) -> Self {
|
||||
Self {
|
||||
src,
|
||||
pos: 0,
|
||||
line: 1,
|
||||
line_start: true,
|
||||
expr: true,
|
||||
stmt: true,
|
||||
restricted: false,
|
||||
member: false,
|
||||
cond: false,
|
||||
parens: vec![],
|
||||
deeper: 0,
|
||||
braces: vec![],
|
||||
deeper_braces: 0,
|
||||
function: None,
|
||||
questions: vec![0],
|
||||
deeper_questions: 0,
|
||||
xml: vec![],
|
||||
xml_line: 0,
|
||||
nesting,
|
||||
unclosed_xml: None,
|
||||
breaks: Breaks::default(),
|
||||
steps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines read so far: the line breaks, and one more for a last line without one.
|
||||
pub fn lines(&self) -> usize {
|
||||
let last = self.src.chars().next_back();
|
||||
self.line - usize::from(last.is_none_or(is_break))
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'s str {
|
||||
self.src.get(self.pos..).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.rest().chars().next()
|
||||
}
|
||||
|
||||
fn peek_at(&self, n: usize) -> Option<char> {
|
||||
self.rest().chars().nth(n)
|
||||
}
|
||||
|
||||
fn starts(&self, s: &str) -> bool {
|
||||
self.rest().starts_with(s)
|
||||
}
|
||||
|
||||
fn bump(&mut self, c: char) {
|
||||
self.pos += c.len_utf8();
|
||||
self.steps += 1;
|
||||
}
|
||||
|
||||
/// Read `n` ASCII characters.
|
||||
fn skip(&mut self, n: usize) {
|
||||
self.pos += n;
|
||||
self.steps += 1;
|
||||
}
|
||||
|
||||
/// A line break here, read: false when there is none.
|
||||
fn line_break(&mut self) -> bool {
|
||||
match self.peek() {
|
||||
Some('\n') => {
|
||||
self.pos += 1;
|
||||
self.breaks.lf = true;
|
||||
}
|
||||
Some('\r') if self.starts("\r\n") => {
|
||||
self.pos += 2;
|
||||
self.breaks.crlf = true;
|
||||
}
|
||||
Some('\r') => {
|
||||
self.pos += 1;
|
||||
self.breaks.cr = true;
|
||||
}
|
||||
Some(c @ ('\u{2028}' | '\u{2029}')) => self.pos += c.len_utf8(),
|
||||
_ => return false,
|
||||
}
|
||||
self.line += 1;
|
||||
self.steps += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Up to the next line break.
|
||||
fn until_line_break(&mut self) {
|
||||
while let Some(c) = self.peek().filter(|&c| !is_break(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u`
|
||||
/// escapes.
|
||||
fn name(&mut self) {
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some('\\') if self.peek_at(1) == Some('u') => {
|
||||
self.skip(2);
|
||||
for _ in 0..4 {
|
||||
match self.peek() {
|
||||
Some(h) if h.is_ascii_hexdigit() => self.bump(h),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(c) if ident_part(c) => self.bump(c),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn digits(&mut self, ok: fn(char) -> bool) {
|
||||
while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') {
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// A number: decimal (with a fraction and an exponent), hexadecimal, or the later binary,
|
||||
/// octal and `n` (big integer) forms.
|
||||
fn number(&mut self) {
|
||||
let b = self.rest().as_bytes();
|
||||
match (b.first(), b.get(1)) {
|
||||
(Some(b'0'), Some(b'x' | b'X')) => {
|
||||
self.skip(2);
|
||||
self.digits(|c| c.is_ascii_hexdigit());
|
||||
}
|
||||
(Some(b'0'), Some(b'b' | b'B' | b'o' | b'O')) => {
|
||||
self.skip(2);
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
}
|
||||
_ => {
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
if self.peek() == Some('.') {
|
||||
self.bump('.');
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
}
|
||||
let signed = matches!(self.peek_at(1), Some('+' | '-')) && self.peek_at(2).is_some_and(|c| c.is_ascii_digit());
|
||||
if matches!(self.peek(), Some('e' | 'E')) && (self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) || signed) {
|
||||
self.skip(if signed { 2 } else { 1 });
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.peek() == Some('n') {
|
||||
self.bump('n');
|
||||
}
|
||||
}
|
||||
|
||||
/// A `'…'` or `"…"` string, which ends at its line's end when it isn't closed.
|
||||
fn string(&mut self, quote: char) -> Option<&'static str> {
|
||||
self.bump(quote);
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Some(UNTERMINATED_STRING),
|
||||
Some(c) if c == quote => {
|
||||
self.bump(c);
|
||||
return None;
|
||||
}
|
||||
Some('\\') => {
|
||||
self.bump('\\');
|
||||
// A backslash before a line break carries the string on to the next line.
|
||||
if !self.line_break()
|
||||
&& let Some(c) = self.peek()
|
||||
{
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
Some(c) if is_break(c) => return Some(UNTERMINATED_STRING),
|
||||
Some(c) => self.bump(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn template(&mut self) -> Option<&'static str> {
|
||||
self.bump('`');
|
||||
loop {
|
||||
if self.line_break() {
|
||||
continue;
|
||||
}
|
||||
match self.peek() {
|
||||
None => return Some(UNTERMINATED_TEMPLATE),
|
||||
Some('`') => {
|
||||
self.bump('`');
|
||||
return None;
|
||||
}
|
||||
Some('\\') => {
|
||||
self.bump('\\');
|
||||
if !self.line_break()
|
||||
&& let Some(c) = self.peek()
|
||||
{
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
Some(c) => self.bump(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A regular expression and its flags, which ends at its line's end when it isn't closed.
|
||||
fn regexp(&mut self) -> Option<&'static str> {
|
||||
self.bump('/');
|
||||
let mut class = false;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Some(UNTERMINATED_REGEXP),
|
||||
Some(c) if is_break(c) => return Some(UNTERMINATED_REGEXP),
|
||||
Some('\\') => {
|
||||
self.bump('\\');
|
||||
if let Some(c) = self.peek().filter(|&c| !is_break(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
Some('/') if !class => {
|
||||
self.bump('/');
|
||||
break;
|
||||
}
|
||||
Some(c) => {
|
||||
match c {
|
||||
'[' => class = true,
|
||||
']' => class = false,
|
||||
_ => {}
|
||||
}
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some(c) = self.peek().filter(|&c| ident_part(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn block_comment(&mut self) -> Option<&'static str> {
|
||||
self.skip(2);
|
||||
loop {
|
||||
if self.starts("*/") {
|
||||
self.skip(2);
|
||||
return None;
|
||||
}
|
||||
if self.line_break() {
|
||||
continue;
|
||||
}
|
||||
match self.peek() {
|
||||
Some(c) => self.bump(c),
|
||||
None => return Some(UNTERMINATED_COMMENT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to and past `end`: false when the text ends first.
|
||||
fn past(&mut self, end: &str) -> bool {
|
||||
loop {
|
||||
if self.starts(end) {
|
||||
self.skip(end.len());
|
||||
return true;
|
||||
}
|
||||
if self.line_break() {
|
||||
continue;
|
||||
}
|
||||
match self.peek() {
|
||||
Some(c) => self.bump(c),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A token of `kind` that starts at `start` on `line`: the markup of the XML literal being
|
||||
/// read, up to the literal's end or up to and with the `{` of the code in it. A `{` whose code
|
||||
/// would take a level past `nesting` is read as markup, and the token has the problem
|
||||
/// [`TOO_DEEP`]. When the text ends first, the literal is left unclosed
|
||||
/// ([`Lexer::unclosed_xml`]). `stmt`: a statement may start at the token; `expr`: an expression
|
||||
/// may start at it.
|
||||
fn markup_token(&mut self, start: usize, line: usize, kind: T, stmt: bool, expr: bool) -> Token<'s> {
|
||||
let mut problem = None;
|
||||
while let Some(&Level::Xml { mut open, mut at }) = self.xml.last() {
|
||||
let step = self.markup(&mut open, &mut at);
|
||||
if let Some(level) = self.xml.last_mut() {
|
||||
*level = Level::Xml { open, at };
|
||||
}
|
||||
match step {
|
||||
Step::On => {}
|
||||
Step::Code if self.xml.len() < self.nesting => {
|
||||
self.bump('{');
|
||||
self.xml.push(Level::Code { braces: 1 });
|
||||
self.open_level();
|
||||
self.expr = true;
|
||||
break;
|
||||
}
|
||||
Step::Code => {
|
||||
self.bump('{');
|
||||
problem = Some(TOO_DEEP);
|
||||
}
|
||||
Step::Up => {
|
||||
// An XML literal is a value: no expression starts after it.
|
||||
self.xml.pop();
|
||||
self.expr = false;
|
||||
break;
|
||||
}
|
||||
Step::End => {
|
||||
self.xml.clear();
|
||||
self.unclosed_xml = Some(self.xml_line);
|
||||
self.expr = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The code in an XML literal's braces is an expression, and so is the literal: no statement
|
||||
// starts after either.
|
||||
self.line_start = false;
|
||||
self.stmt = false;
|
||||
self.cond = false;
|
||||
self.restricted = false;
|
||||
self.member = false;
|
||||
Token { kind, text: self.src.get(start..self.pos).unwrap_or_default(), line, problem, stmt, expr, start }
|
||||
}
|
||||
|
||||
/// A little more of an XML literal's markup, `open` of its elements open and `at` where in it:
|
||||
/// a comment, CDATA section or processing instruction whole, or one character of a tag or of
|
||||
/// an element's text.
|
||||
fn markup(&mut self, open: &mut usize, at: &mut At) -> Step {
|
||||
match *at {
|
||||
At::Markup => {
|
||||
let read = if self.starts("<!--") {
|
||||
self.past("-->")
|
||||
} else if self.starts("<![CDATA[") {
|
||||
self.past("]]>")
|
||||
} else if self.starts("<?") {
|
||||
self.past("?>")
|
||||
} else {
|
||||
let end = self.starts("</");
|
||||
self.skip(if end { 2 } else { 1 });
|
||||
*at = At::Tag { end };
|
||||
return Step::On;
|
||||
};
|
||||
*at = At::Text;
|
||||
match (read, *open) {
|
||||
(false, _) => Step::End,
|
||||
(true, 0) => Step::Up,
|
||||
(true, _) => Step::On,
|
||||
}
|
||||
}
|
||||
At::Tag { end } => {
|
||||
if self.line_break() {
|
||||
return Step::On;
|
||||
}
|
||||
// `>` ends a tag, and `/>` a tag whose element closes itself.
|
||||
let closes_itself = match self.peek() {
|
||||
None => return Step::End,
|
||||
Some('>') => {
|
||||
self.bump('>');
|
||||
false
|
||||
}
|
||||
Some('/') if self.peek_at(1) == Some('>') => {
|
||||
self.skip(2);
|
||||
true
|
||||
}
|
||||
Some(q @ ('"' | '\'')) => {
|
||||
self.bump(q);
|
||||
return if self.past(if q == '"' { "\"" } else { "'" }) { Step::On } else { Step::End };
|
||||
}
|
||||
Some('{') => return Step::Code,
|
||||
Some(c) => {
|
||||
self.bump(c);
|
||||
return Step::On;
|
||||
}
|
||||
};
|
||||
*open = match (end, closes_itself) {
|
||||
(true, _) => open.saturating_sub(1),
|
||||
(false, false) => open.saturating_add(1),
|
||||
(false, true) => *open,
|
||||
};
|
||||
*at = At::Text;
|
||||
if *open == 0 { Step::Up } else { Step::On }
|
||||
}
|
||||
At::Text => {
|
||||
if self.line_break() {
|
||||
return Step::On;
|
||||
}
|
||||
match self.peek() {
|
||||
None => Step::End,
|
||||
Some('<') => {
|
||||
*at = At::Markup;
|
||||
Step::On
|
||||
}
|
||||
Some('{') => Step::Code,
|
||||
Some(c) => {
|
||||
self.bump(c);
|
||||
Step::On
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn punct(&self) -> Option<&'static str> {
|
||||
let rest = self.rest();
|
||||
// `?.` before a digit is `?` and a number (`a ?.5 : 1`).
|
||||
PUNCTS.iter().copied().find(|p| rest.starts_with(p) && !(*p == "?." && rest.as_bytes().get(2).is_some_and(u8::is_ascii_digit)))
|
||||
}
|
||||
|
||||
/// The parentheses after the punctuator `punct`: `(` opens one, a condition when `cond` is set,
|
||||
/// and `)` closes the last one open. True when `punct` closes a condition.
|
||||
fn paren(&mut self, punct: &str) -> bool {
|
||||
match punct {
|
||||
"(" if self.parens.len() < self.nesting => self.parens.push(self.cond),
|
||||
"(" => self.deeper = self.deeper.saturating_add(1),
|
||||
")" if self.deeper > 0 => self.deeper -= 1,
|
||||
")" => return self.parens.pop().unwrap_or(false),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The braces after the punctuator `punct`: `{` opens one and `}` closes the last one open. For
|
||||
/// `{` and `}`, what the brace opened: the body of the function just read, a block where a
|
||||
/// statement may start or no expression may, else an object literal. A brace past `nesting` is
|
||||
/// taken as a block's when it closes.
|
||||
fn brace(&mut self, punct: &str) -> Option<Brace> {
|
||||
match punct {
|
||||
"{" => {
|
||||
let brace = match self.function.take() {
|
||||
Some(true) => Brace::Function,
|
||||
Some(false) => Brace::Block,
|
||||
None if self.stmt || !self.expr => Brace::Block,
|
||||
None => Brace::Object,
|
||||
};
|
||||
if self.braces.len() < self.nesting {
|
||||
self.braces.push(brace);
|
||||
} else {
|
||||
self.deeper_braces = self.deeper_braces.saturating_add(1);
|
||||
}
|
||||
Some(brace)
|
||||
}
|
||||
"}" if self.deeper_braces > 0 => {
|
||||
self.deeper_braces -= 1;
|
||||
Some(Brace::Block)
|
||||
}
|
||||
"}" => Some(self.braces.pop().unwrap_or(Brace::Block)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A bracket opens: the `?` inside it are counted apart from the ones outside.
|
||||
fn open_level(&mut self) {
|
||||
if self.questions.len() <= self.nesting {
|
||||
self.questions.push(0);
|
||||
} else {
|
||||
self.deeper_questions = self.deeper_questions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The innermost bracket closes.
|
||||
fn close_level(&mut self) {
|
||||
if self.deeper_questions > 0 {
|
||||
self.deeper_questions -= 1;
|
||||
} else if self.questions.len() > 1 {
|
||||
self.questions.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The punctuator `punct` at the innermost level: `?` opens a conditional there, and a `:`
|
||||
/// there while one is open is that conditional's. True for that `:`.
|
||||
fn conditional(&mut self, punct: &str) -> bool {
|
||||
if self.deeper_questions > 0 {
|
||||
return false;
|
||||
}
|
||||
let Some(open) = self.questions.last_mut() else { return false };
|
||||
match punct {
|
||||
"?" => *open = open.saturating_add(1),
|
||||
":" if *open > 0 => {
|
||||
*open -= 1;
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A line break after the last significant token. After the end of an expression a statement
|
||||
/// may start (JavaScript ends the statement at the line break when the next token can't go on
|
||||
/// with it), and after `return`, `throw`, `break` and `continue` a new statement starts.
|
||||
fn line_ended(&mut self) {
|
||||
if std::mem::take(&mut self.restricted) {
|
||||
self.expr = true;
|
||||
self.stmt = true;
|
||||
}
|
||||
self.stmt |= !self.expr;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'s> Iterator for Lexer<'s> {
|
||||
type Item = Token<'s>;
|
||||
|
||||
fn next(&mut self) -> Option<Token<'s>> {
|
||||
let (start, line, stmt, expr) = (self.pos, self.line, self.stmt, self.expr);
|
||||
let Some(c) = self.peek() else {
|
||||
// The text ends in the code of an XML literal.
|
||||
if !self.xml.is_empty() {
|
||||
self.xml.clear();
|
||||
self.unclosed_xml = Some(self.xml_line);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let mut problem = None;
|
||||
let kind = if self.line_break() {
|
||||
T::Newline
|
||||
} else if is_blank(c) {
|
||||
while let Some(c) = self.peek().filter(|&c| is_blank(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
T::Space
|
||||
} else if self.starts("//") {
|
||||
let directive = self.line_start && self.rest().strip_prefix("//@").is_some_and(names_directive);
|
||||
self.until_line_break();
|
||||
if directive { T::Directive } else { T::Comment }
|
||||
} else if self.starts("/*") {
|
||||
problem = self.block_comment();
|
||||
T::Comment
|
||||
} else if c == '#' && self.line_start {
|
||||
self.until_line_break();
|
||||
T::Directive
|
||||
} else if c == '#' && self.peek_at(1).is_some_and(ident_start) {
|
||||
self.bump('#');
|
||||
self.name();
|
||||
T::Other
|
||||
} else if c == '\'' || c == '"' {
|
||||
problem = self.string(c);
|
||||
T::Str
|
||||
} else if c == '`' {
|
||||
problem = self.template();
|
||||
T::Template
|
||||
} else if c == '/' && self.expr {
|
||||
problem = self.regexp();
|
||||
T::RegExp
|
||||
} else if c == '<' && self.expr && self.peek_at(1).is_some_and(xml_start) {
|
||||
if self.xml.len() < self.nesting {
|
||||
// An XML literal starts, in the script or in the code of another one.
|
||||
if self.xml.is_empty() {
|
||||
self.xml_line = line;
|
||||
}
|
||||
self.xml.push(Level::Xml { open: 0, at: At::Markup });
|
||||
return Some(self.markup_token(start, line, T::Xml, stmt, expr));
|
||||
}
|
||||
// An XML literal more levels deep than are kept: its `<` is read as an operator.
|
||||
self.skip(1);
|
||||
problem = Some(TOO_DEEP);
|
||||
T::Punct
|
||||
} else if c == '}' && matches!(self.xml.last(), Some(Level::Code { braces: 1 })) {
|
||||
// The `}` that ends the code in an XML literal's braces: the literal's markup goes on.
|
||||
self.xml.pop();
|
||||
self.close_level();
|
||||
self.bump('}');
|
||||
return Some(self.markup_token(start, line, T::XmlMore, stmt, expr));
|
||||
} else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) {
|
||||
self.name();
|
||||
T::Ident
|
||||
} else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) {
|
||||
self.number();
|
||||
T::Number
|
||||
} else if let Some(p) = self.punct() {
|
||||
self.skip(p.len());
|
||||
T::Punct
|
||||
} else {
|
||||
self.bump(c);
|
||||
T::Other
|
||||
};
|
||||
let text = self.src.get(start..self.pos).unwrap_or_default();
|
||||
match kind {
|
||||
T::Space => {}
|
||||
T::Newline => {
|
||||
self.line_start = true;
|
||||
self.line_ended();
|
||||
}
|
||||
// A comment that holds a line break counts as one.
|
||||
T::Comment => {
|
||||
self.line_start = false;
|
||||
if text.contains(is_break) {
|
||||
self.line_ended();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.line_start = false;
|
||||
// A name after `.`, `..`, `.@` or `::` is a property's, also when it is a keyword: no
|
||||
// expression or statement starts after it.
|
||||
let property = kind == T::Ident && self.member;
|
||||
// After the `)` of a condition comes a statement, which may start with a regular
|
||||
// expression or an XML literal.
|
||||
let condition = kind == T::Punct && self.paren(text);
|
||||
// A statement may also start after the `}` of a block. After the `}` of an object
|
||||
// literal or of a function expression's body, `/` divides.
|
||||
let brace = if kind == T::Punct { self.brace(text) } else { None };
|
||||
self.expr = condition
|
||||
|| match (text, brace) {
|
||||
("}", Some(brace)) => brace == Brace::Block,
|
||||
_ => !property && expr_after(kind, text),
|
||||
};
|
||||
if kind == T::Ident && !property && text == "function" {
|
||||
self.function = Some(!self.stmt);
|
||||
}
|
||||
if kind == T::Punct {
|
||||
match text {
|
||||
"(" | "[" | "{" => self.open_level(),
|
||||
")" | "]" | "}" => self.close_level(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ternary = kind == T::Punct && self.conditional(text);
|
||||
// A statement may start inside a block or a function's body, after `;`, `=>`, a
|
||||
// preprocessor line, `else`, `do`, `try` and `finally`, and after the `:` of a label or
|
||||
// a `case` (not a conditional's, and outside an object literal).
|
||||
self.stmt = condition
|
||||
|| match (text, brace) {
|
||||
("{", Some(brace)) => brace != Brace::Object,
|
||||
("}", Some(brace)) => brace == Brace::Block,
|
||||
_ => false,
|
||||
}
|
||||
|| kind == T::Directive
|
||||
|| (kind == T::Punct && matches!(text, ";" | "=>"))
|
||||
|| (kind == T::Punct && text == ":" && !ternary && self.braces.last() != Some(&Brace::Object))
|
||||
|| (kind == T::Ident && !property && matches!(text, "else" | "do" | "try" | "finally"));
|
||||
self.cond = kind == T::Ident && !property && (matches!(text, "if" | "while" | "for" | "with") || (text == "each" && self.cond));
|
||||
self.restricted = kind == T::Ident && !property && matches!(text, "return" | "throw" | "break" | "continue");
|
||||
self.member = kind == T::Punct && matches!(text, "." | ".." | ".@" | "::");
|
||||
// The braces inside the code of an XML literal.
|
||||
if kind == T::Punct
|
||||
&& let Some(Level::Code { braces }) = self.xml.last_mut()
|
||||
{
|
||||
match text {
|
||||
"{" => *braces = braces.saturating_add(1),
|
||||
"}" => *braces = braces.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Token { kind, text, line, problem, stmt, expr, start })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Static checker for scripts written for the reference app's scripting: `.jsx`, `.jsxinc` and
|
||||
//! `.js` files, and compiled `.jsxbin` files. [`check`] reads a script's text without running any
|
||||
//! of it and reports what kind of file it is ([`Kind`]), its preprocessor directives, the platform
|
||||
//! features and document-model member chains it uses, what it would do outside the open document,
|
||||
//! the menu-command strings it passes, the files it includes and the problems that would stop it
|
||||
//! loading.
|
||||
//!
|
||||
//! The lexer reads ECMAScript 3 (ECMA-262 3rd edition,
|
||||
//! <https://ecma-international.org/wp-content/uploads/ECMA-262_3rd_edition_december_1999.pdf>) with
|
||||
//! the XML literals and operators of E4X (ECMA-357 2nd edition,
|
||||
//! <https://ecma-international.org/wp-content/uploads/ECMA-357_2nd_edition_december_2005.pdf>), and
|
||||
//! it reads later syntax (template literals, `=>`) only to report it. The directive and platform
|
||||
//! names come from the public pages `catalog.rs` cites by URL; only the names are used. Nothing
|
||||
//! maps the reference app's names to VectorCraft commands.
|
||||
//!
|
||||
//! The checker never recurses: it reads each file once, and each scan of a file goes through its
|
||||
//! tokens front to back, first for the names its functions and its global code declare and then
|
||||
//! for the analysis. When it follows includes, it scans each file for the includes and what the
|
||||
//! variables hold, again while that grows (at most three more times), and then for the report. It
|
||||
//! keeps open brackets and the levels of an XML literal on capped stacks. Its work is linear in the
|
||||
//! input's size, and no input can overflow the stack.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod analyze;
|
||||
mod catalog;
|
||||
mod directives;
|
||||
mod includes;
|
||||
mod lex;
|
||||
mod report;
|
||||
|
||||
pub use includes::{Loaded, Loader};
|
||||
pub use report::{Directive, Feature, Include, IncludeStatus, MenuString, Named, Outside, Problem, Report, Target};
|
||||
|
||||
/// The limits that keep a check small and quick; [`Limits::default`] holds the ones VectorCraft
|
||||
/// uses.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// The most bytes of one file read: a larger script is [`Kind::TooLarge`], and a larger
|
||||
/// included file isn't read.
|
||||
pub file_bytes: usize,
|
||||
/// The most bytes read from the script and its included files together.
|
||||
pub total_bytes: usize,
|
||||
/// The most included files read.
|
||||
pub includes: usize,
|
||||
/// How deep includes are followed: a file the script includes is at depth 1.
|
||||
pub include_depth: usize,
|
||||
/// The most paths looked up for included files, whether a file is found there or not.
|
||||
pub lookups: usize,
|
||||
/// The most open brackets tracked, and the most levels an XML literal is read to (its markup,
|
||||
/// the code in a `{…}` inside it, an XML literal in that code). Deeper nesting is a problem, and
|
||||
/// the check goes on.
|
||||
pub nesting: usize,
|
||||
/// The most entries in each list of the report, and in each table of names the check keeps (the
|
||||
/// variables holding a member chain, the variables holding a File or Folder object, the names
|
||||
/// one function declares, and the functions the global code declares).
|
||||
pub entries: usize,
|
||||
/// The most line numbers listed for one entry.
|
||||
pub lines: usize,
|
||||
/// The most characters of text echoed from a script (names, values, member chains).
|
||||
pub echo: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
file_bytes: 8 << 20,
|
||||
total_bytes: 32 << 20,
|
||||
includes: 64,
|
||||
include_depth: 16,
|
||||
lookups: 1024,
|
||||
nesting: 4096,
|
||||
entries: 1000,
|
||||
lines: 20,
|
||||
echo: 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of file a script is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Text that reads as a script.
|
||||
Script,
|
||||
/// A compiled script (`.jsxbin`, or text that starts with `@JSXBIN@`). It isn't read.
|
||||
Compiled,
|
||||
/// A JavaScript module (top-level `import` or `export`), such as a React `.jsx` file: not a
|
||||
/// script for the reference app.
|
||||
Module,
|
||||
/// Not text: a NUL byte, or more than one byte in ten a control character, in the first 8 KiB.
|
||||
NotText,
|
||||
/// Larger than [`Limits::file_bytes`].
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Script => "script",
|
||||
Kind::Compiled => "compiled",
|
||||
Kind::Module => "module",
|
||||
Kind::NotText => "notText",
|
||||
Kind::TooLarge => "tooLarge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind of help a script needs from the app that runs it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Need {
|
||||
/// The script language's own objects and directives (`$`, `alert`, `UnitValue`, `#target`).
|
||||
Language,
|
||||
/// The reference app's document model (`app.activeDocument…`).
|
||||
Dom,
|
||||
/// Files and folders.
|
||||
Files,
|
||||
/// Menu commands run by name.
|
||||
MenuCommands,
|
||||
/// Dialogs and windows of its own.
|
||||
Dialogs,
|
||||
/// Features only the reference app has (palettes, messages to other apps, network
|
||||
/// connections, native code, E4X, actions).
|
||||
HostOnly,
|
||||
}
|
||||
|
||||
impl Need {
|
||||
pub const ALL: [Need; 6] = [Need::Language, Need::Dom, Need::Files, Need::MenuCommands, Need::Dialogs, Need::HostOnly];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Need::Language => "language",
|
||||
Need::Dom => "dom",
|
||||
Need::Files => "files",
|
||||
Need::MenuCommands => "menuCommands",
|
||||
Need::Dialogs => "dialogs",
|
||||
Need::HostOnly => "hostOnly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every feature id a report can list, in the order the report lists them.
|
||||
pub use catalog::FEATURES;
|
||||
/// Every id in [`Report::outside`], in the order the report lists them.
|
||||
pub use report::OUTSIDE;
|
||||
/// Every problem code a report can list.
|
||||
pub use report::PROBLEMS;
|
||||
|
||||
/// Check the script `bytes`, named `name` (a file name; only its last part is used). With a
|
||||
/// `loader`, the files its `#include` lines name are read through it and checked too; without one
|
||||
/// they are listed as [`IncludeStatus::NotRead`]. When the script or one of those files assigns a
|
||||
/// member chain or a File object to a variable, the check treats the variable as holding it in
|
||||
/// every file. Nothing in the script runs.
|
||||
pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: &Limits) -> Report {
|
||||
let mut acc = report::Acc::new(limits);
|
||||
if bytes.len() > limits.file_bytes {
|
||||
return acc.finish(Kind::TooLarge, 0);
|
||||
}
|
||||
let name = base_name(name);
|
||||
if is_compiled(name, bytes) {
|
||||
return acc.finish(Kind::Compiled, 0);
|
||||
}
|
||||
if !is_text(bytes) {
|
||||
return acc.finish(Kind::NotText, 0);
|
||||
}
|
||||
let (text, invalid) = decode(bytes);
|
||||
if invalid {
|
||||
acc.problem(report::NOT_UTF8, None, None, None);
|
||||
}
|
||||
let none = analyze::Known::default();
|
||||
// With a loader, this first scan finds the includes and what the script's variables hold; its
|
||||
// tallies are left out.
|
||||
let mut tallies = report::Acc::new(limits);
|
||||
let scan = analyze::scan(&text, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
if scan.module {
|
||||
// A module's report holds its kind and its lines, and none of what the scan found.
|
||||
let steps = acc.steps;
|
||||
let mut acc = report::Acc::new(limits);
|
||||
acc.steps = steps;
|
||||
return acc.finish(Kind::Module, scan.lines);
|
||||
}
|
||||
let Some(loader) = loader else {
|
||||
includes::not_read(&mut acc, scan.includes);
|
||||
return acc.finish(Kind::Script, scan.lines);
|
||||
};
|
||||
let mut known = scan.known;
|
||||
let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known);
|
||||
// A variable may get what it holds from another file's variable: the files are scanned again,
|
||||
// each with what is known so far, while that grows, at most three more times.
|
||||
for _ in 0..3 {
|
||||
let before = known.len();
|
||||
let mut tallies = report::Acc::new(limits);
|
||||
let more = analyze::scan(&text, None, std::rc::Rc::from([]), &known, &mut tallies).known;
|
||||
known.add(more, limits.entries);
|
||||
for file in &read {
|
||||
let more = analyze::scan(&file.text, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
|
||||
known.add(more, limits.entries);
|
||||
}
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
if known.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The scans for the report: every file starts with what any file's variables hold.
|
||||
let lines = analyze::scan(&text, None, std::rc::Rc::from([]), &known, &mut acc).lines;
|
||||
for file in read {
|
||||
if file.invalid {
|
||||
acc.problem(report::NOT_UTF8, None, Some(&file.path), None);
|
||||
}
|
||||
analyze::scan(&file.text, Some(&file.path), file.paths, &known, &mut acc);
|
||||
}
|
||||
acc.finish(Kind::Script, lines)
|
||||
}
|
||||
|
||||
/// The last part of a path (`lib/Tidy.jsx` → `Tidy.jsx`), whichever separator it uses.
|
||||
pub(crate) fn base_name(name: &str) -> &str {
|
||||
name.rsplit(['/', '\\']).next().unwrap_or(name)
|
||||
}
|
||||
|
||||
/// A compiled script: named `.jsxbin`, or text that starts with `@JSXBIN@` (after a byte order mark
|
||||
/// and blanks).
|
||||
pub(crate) fn is_compiled(name: &str, bytes: &[u8]) -> bool {
|
||||
if base_name(name).rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("jsxbin")) {
|
||||
return true;
|
||||
}
|
||||
let b = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len());
|
||||
b.get(start..).is_some_and(|b| b.starts_with(b"@JSXBIN@"))
|
||||
}
|
||||
|
||||
/// Text, judged by its first 8 KiB: no NUL byte, and at most one byte in ten a control character
|
||||
/// other than tab, line feed, vertical tab, form feed, carriage return and escape.
|
||||
pub(crate) fn is_text(bytes: &[u8]) -> bool {
|
||||
let head = bytes.get(..bytes.len().min(8 << 10)).unwrap_or(bytes);
|
||||
if head.contains(&0) {
|
||||
return false;
|
||||
}
|
||||
let controls = head.iter().filter(|&&b| b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r' | 0x0b | 0x0c | 0x1b)).count();
|
||||
controls * 10 <= head.len()
|
||||
}
|
||||
|
||||
/// The text of `bytes`: UTF-8 after an optional byte order mark, with invalid bytes replaced by
|
||||
/// U+FFFD (`true` when there were any).
|
||||
pub(crate) fn decode(bytes: &[u8]) -> (std::borrow::Cow<'_, str>, bool) {
|
||||
let b = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let text = String::from_utf8_lossy(b);
|
||||
let invalid = matches!(text, std::borrow::Cow::Owned(_));
|
||||
(text, invalid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,417 @@
|
||||
//! What a check finds ([`Report`]), and the tallies it is built from ([`Acc`]), each list capped.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{Kind, Limits, Need, catalog};
|
||||
|
||||
pub(crate) const UNTERMINATED_STRING: &str = "unterminatedString";
|
||||
pub(crate) const UNTERMINATED_COMMENT: &str = "unterminatedComment";
|
||||
pub(crate) const UNTERMINATED_REGEXP: &str = "unterminatedRegExp";
|
||||
pub(crate) const UNTERMINATED_TEMPLATE: &str = "unterminatedTemplate";
|
||||
pub(crate) const UNTERMINATED_XML: &str = "unterminatedXml";
|
||||
pub(crate) const UNCLOSED_BRACKET: &str = "unclosedBracket";
|
||||
pub(crate) const UNEXPECTED_BRACKET: &str = "unexpectedBracket";
|
||||
pub(crate) const NEWER_SYNTAX: &str = "newerSyntax";
|
||||
pub(crate) const UNKNOWN_DIRECTIVE: &str = "unknownDirective";
|
||||
pub(crate) const NOT_UTF8: &str = "notUtf8";
|
||||
pub(crate) const MIXED_LINE_ENDINGS: &str = "mixedLineEndings";
|
||||
pub(crate) const TOO_DEEP: &str = "tooDeep";
|
||||
|
||||
/// Every problem code a report can list.
|
||||
pub const PROBLEMS: &[&str] = &[
|
||||
UNTERMINATED_STRING,
|
||||
UNTERMINATED_COMMENT,
|
||||
UNTERMINATED_REGEXP,
|
||||
UNTERMINATED_TEMPLATE,
|
||||
UNTERMINATED_XML,
|
||||
UNCLOSED_BRACKET,
|
||||
UNEXPECTED_BRACKET,
|
||||
NEWER_SYNTAX,
|
||||
UNKNOWN_DIRECTIVE,
|
||||
NOT_UTF8,
|
||||
MIXED_LINE_ENDINGS,
|
||||
TOO_DEEP,
|
||||
];
|
||||
|
||||
/// Every id in [`Report::outside`], in the order the report lists them: files written, moved or
|
||||
/// deleted; files opened in other apps; network connections; native code; messages to other apps;
|
||||
/// environment variables; code the checker can't read; the reference app's preferences changed.
|
||||
pub const OUTSIDE: &[&str] = &["filesWritten", "filesOpened", "network", "nativeCode", "otherApps", "environment", "unseenCode", "preferences"];
|
||||
|
||||
/// What [`crate::check`] found in a script. Lists of located things (targets, directives,
|
||||
/// includes, problems) give a line and, for an included file, its path (`file`); tallies (features,
|
||||
/// menu strings, outside effects) count the script and its included files together and list the
|
||||
/// script's own lines.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
/// What the script needs from the app that runs it, in [`Need`]'s order.
|
||||
pub needs: Vec<Need>,
|
||||
/// The script's lines.
|
||||
pub lines: usize,
|
||||
/// Each `#target` line's application specifier.
|
||||
pub targets: Vec<Target>,
|
||||
pub directives: Vec<Directive>,
|
||||
pub includes: Vec<Include>,
|
||||
/// The platform features used, by id ([`crate::FEATURES`]).
|
||||
pub features: Vec<Feature>,
|
||||
/// The document-model member chains used, as written (`app.activeDocument.layers[].name`, with
|
||||
/// `[]` for an index and `()` for a call), a variable assigned from a chain replaced by that
|
||||
/// chain, and how often each occurs; the most used first.
|
||||
pub members: Vec<Named>,
|
||||
/// The type names compared with `.typename` or created with `new`, apart from the language's,
|
||||
/// the platform's and the script's own functions.
|
||||
pub types: Vec<Named>,
|
||||
/// The menu-command strings passed as string literals; the most used first.
|
||||
pub menu_strings: Vec<MenuString>,
|
||||
/// Menu-command calls whose argument isn't a string literal.
|
||||
pub menu_dynamic: usize,
|
||||
/// What the script does outside the document, in [`OUTSIDE`]'s order.
|
||||
pub outside: Vec<Outside>,
|
||||
pub problems: Vec<Problem>,
|
||||
/// A list of the report, or a table of names the check keeps, reached [`Limits::entries`], and
|
||||
/// entries were left out.
|
||||
pub truncated: bool,
|
||||
/// The steps the check took: the tests check that they grow linearly with the input.
|
||||
pub(crate) steps: usize,
|
||||
}
|
||||
|
||||
/// A `#target` line's application specifier, split into its parts.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Target {
|
||||
pub value: String,
|
||||
pub app: String,
|
||||
pub instance: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub line: usize,
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
/// A preprocessor line of a documented directive.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Directive {
|
||||
/// The directive (`target`, `include`…).
|
||||
pub name: &'static str,
|
||||
pub value: String,
|
||||
pub line: usize,
|
||||
/// `#` or `//@`.
|
||||
pub form: &'static str,
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
/// What became of an `#include` line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IncludeStatus {
|
||||
/// Read and checked (or checked already, for a file included twice).
|
||||
Checked,
|
||||
/// No such file in the script's folder, or in the folders `#includepath` names inside it.
|
||||
Missing,
|
||||
/// Not followed: an absolute name, a name longer than 4,096 bytes, a name that leads out of the
|
||||
/// script's folder, or a file the caller may not read.
|
||||
NotFollowed,
|
||||
/// The file it names includes the file the line is in, directly or through other files.
|
||||
Cycle,
|
||||
/// Includes weren't read (no loader).
|
||||
NotRead,
|
||||
/// Not read: a limit was reached (files, depth, size, paths looked up).
|
||||
Limit,
|
||||
/// A compiled file, or not text.
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl IncludeStatus {
|
||||
pub const ALL: [IncludeStatus; 7] = [
|
||||
IncludeStatus::Checked,
|
||||
IncludeStatus::Missing,
|
||||
IncludeStatus::NotFollowed,
|
||||
IncludeStatus::Cycle,
|
||||
IncludeStatus::NotRead,
|
||||
IncludeStatus::Limit,
|
||||
IncludeStatus::Unreadable,
|
||||
];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
IncludeStatus::Checked => "checked",
|
||||
IncludeStatus::Missing => "missing",
|
||||
IncludeStatus::NotFollowed => "notFollowed",
|
||||
IncludeStatus::Cycle => "cycle",
|
||||
IncludeStatus::NotRead => "notRead",
|
||||
IncludeStatus::Limit => "limit",
|
||||
IncludeStatus::Unreadable => "unreadable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `#include` line and what became of it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Include {
|
||||
/// The name as the line writes it.
|
||||
pub value: String,
|
||||
pub line: usize,
|
||||
/// The included file the line is in.
|
||||
pub file: Option<String>,
|
||||
pub status: IncludeStatus,
|
||||
/// The file it names, as a path inside the script's folder, when one was found.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// A platform feature: how often it occurs, and the script's first lines with it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Feature {
|
||||
pub id: &'static str,
|
||||
pub count: usize,
|
||||
pub lines: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A name and how often it occurs.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Named {
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// A menu-command string: how often it is passed, and the script's first lines with it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MenuString {
|
||||
pub value: String,
|
||||
pub count: usize,
|
||||
pub lines: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Something the script does outside the document ([`OUTSIDE`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Outside {
|
||||
pub id: &'static str,
|
||||
pub count: usize,
|
||||
pub lines: Vec<usize>,
|
||||
/// For `filesWritten`: the open modes used (`w`, `a`, `e`).
|
||||
pub modes: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// A problem that would stop the script loading or running as written.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Problem {
|
||||
pub code: &'static str,
|
||||
pub line: Option<usize>,
|
||||
pub file: Option<String>,
|
||||
/// The text concerned, as written (an unknown directive, a construct of later syntax, a
|
||||
/// bracket).
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// A count and the first lines it was seen on.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Tally {
|
||||
count: usize,
|
||||
lines: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn add(&mut self, line: Option<usize>, max: usize) {
|
||||
self.count = self.count.saturating_add(1);
|
||||
if let Some(line) = line
|
||||
&& self.lines.len() < max
|
||||
&& self.lines.last() != Some(&line)
|
||||
{
|
||||
self.lines.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a check has found so far, across the script and its included files.
|
||||
pub(crate) struct Acc<'l> {
|
||||
pub limits: &'l Limits,
|
||||
pub steps: usize,
|
||||
features: BTreeMap<&'static str, Tally>,
|
||||
outside: BTreeMap<&'static str, (Tally, Vec<&'static str>)>,
|
||||
menu: BTreeMap<String, Tally>,
|
||||
menu_dynamic: usize,
|
||||
members: BTreeMap<String, usize>,
|
||||
types: BTreeMap<String, usize>,
|
||||
/// Functions the script declares: `new` of one is no document-model type.
|
||||
declared: BTreeSet<String>,
|
||||
targets: Vec<Target>,
|
||||
directives: Vec<Directive>,
|
||||
includes: Vec<Include>,
|
||||
problems: Vec<Problem>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl<'l> Acc<'l> {
|
||||
pub fn new(limits: &'l Limits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
steps: 0,
|
||||
features: BTreeMap::new(),
|
||||
outside: BTreeMap::new(),
|
||||
menu: BTreeMap::new(),
|
||||
menu_dynamic: 0,
|
||||
members: BTreeMap::new(),
|
||||
types: BTreeMap::new(),
|
||||
declared: BTreeSet::new(),
|
||||
targets: vec![],
|
||||
directives: vec![],
|
||||
includes: vec![],
|
||||
problems: vec![],
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as the report echoes it: at most [`Limits::echo`] characters, the last one `…` when
|
||||
/// it was cut.
|
||||
pub fn echo(&self, s: &str) -> String {
|
||||
echo(s, self.limits.echo)
|
||||
}
|
||||
|
||||
/// Room for one more entry in a list of the report, or in a table of names the check keeps, of
|
||||
/// `len` entries: false (and the report marked truncated) when it is full.
|
||||
pub fn room(&mut self, len: usize) -> bool {
|
||||
let room = len < self.limits.entries;
|
||||
self.truncated |= !room;
|
||||
room
|
||||
}
|
||||
|
||||
pub fn feature(&mut self, id: &'static str, line: Option<usize>) {
|
||||
let max = self.limits.lines;
|
||||
self.features.entry(id).or_default().add(line, max);
|
||||
}
|
||||
|
||||
pub fn outside(&mut self, id: &'static str, line: Option<usize>, mode: Option<&'static str>) {
|
||||
let max = self.limits.lines;
|
||||
let (tally, modes) = self.outside.entry(id).or_default();
|
||||
tally.add(line, max);
|
||||
if let Some(m) = mode
|
||||
&& !modes.contains(&m)
|
||||
{
|
||||
modes.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_string(&mut self, value: &str, line: Option<usize>) {
|
||||
let key = self.echo(value);
|
||||
if !self.menu.contains_key(&key) && !self.room(self.menu.len()) {
|
||||
return;
|
||||
}
|
||||
let max = self.limits.lines;
|
||||
self.menu.entry(key).or_default().add(line, max);
|
||||
}
|
||||
|
||||
pub fn menu_dynamic(&mut self) {
|
||||
self.menu_dynamic = self.menu_dynamic.saturating_add(1);
|
||||
}
|
||||
|
||||
pub fn member(&mut self, path: &str) {
|
||||
if !self.members.contains_key(path) && !self.room(self.members.len()) {
|
||||
return;
|
||||
}
|
||||
let n = self.members.entry(path.to_string()).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
|
||||
pub fn type_name(&mut self, name: &str) {
|
||||
let key = self.echo(name);
|
||||
if !self.types.contains_key(&key) && !self.room(self.types.len()) {
|
||||
return;
|
||||
}
|
||||
let n = self.types.entry(key).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
|
||||
pub fn declared(&mut self, name: &str) {
|
||||
if self.declared.len() < self.limits.entries {
|
||||
self.declared.insert(self.echo(name));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn problem(&mut self, code: &'static str, line: Option<usize>, file: Option<&str>, detail: Option<&str>) {
|
||||
if self.room(self.problems.len()) {
|
||||
let detail = detail.map(|d| self.echo(d));
|
||||
self.problems.push(Problem { code, line, file: file.map(str::to_string), detail });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn directive(&mut self, name: &'static str, value: &str, line: usize, form: &'static str, file: Option<&str>) {
|
||||
if self.room(self.directives.len()) {
|
||||
let value = self.echo(value);
|
||||
self.directives.push(Directive { name, value, line, form, file: file.map(str::to_string) });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(&mut self, value: &str, line: usize, file: Option<&str>) {
|
||||
if !self.room(self.targets.len()) {
|
||||
return;
|
||||
}
|
||||
let t = crate::directives::target(value);
|
||||
let part = |s: Option<&str>| s.map(|s| echo(s, self.limits.echo));
|
||||
let target = Target {
|
||||
value: self.echo(value),
|
||||
app: self.echo(t.app),
|
||||
instance: part(t.instance),
|
||||
version: part(t.version),
|
||||
locale: part(t.locale),
|
||||
line,
|
||||
file: file.map(str::to_string),
|
||||
};
|
||||
self.targets.push(target);
|
||||
}
|
||||
|
||||
pub fn include(&mut self, value: &str, line: usize, file: Option<&str>, status: IncludeStatus, path: Option<&str>) {
|
||||
if self.room(self.includes.len()) {
|
||||
let include = Include { value: self.echo(value), line, file: file.map(str::to_string), status, path: path.map(|p| self.echo(p)) };
|
||||
self.includes.push(include);
|
||||
}
|
||||
}
|
||||
|
||||
/// The report for a file of `kind` with `lines` lines.
|
||||
pub fn finish(self, kind: Kind, lines: usize) -> Report {
|
||||
let declared = self.declared;
|
||||
let mut members: Vec<Named> = self.members.into_iter().map(|(name, count)| Named { name, count }).collect();
|
||||
members.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
|
||||
let mut types: Vec<Named> =
|
||||
self.types.into_iter().filter(|(name, _)| !declared.contains(name)).map(|(name, count)| Named { name, count }).collect();
|
||||
types.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
|
||||
let mut menu_strings: Vec<MenuString> =
|
||||
self.menu.into_iter().map(|(value, t)| MenuString { value, count: t.count, lines: t.lines }).collect();
|
||||
menu_strings.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
|
||||
let features: Vec<Feature> = self.features.into_iter().map(|(id, t)| Feature { id, count: t.count, lines: t.lines }).collect();
|
||||
let mut outside: Vec<Outside> =
|
||||
self.outside.into_iter().map(|(id, (t, modes))| Outside { id, count: t.count, lines: t.lines, modes }).collect();
|
||||
outside.sort_by_key(|o| OUTSIDE.iter().position(|id| *id == o.id));
|
||||
let mut needs: BTreeSet<Need> = features.iter().filter_map(|f| catalog::need(f.id)).collect();
|
||||
if !members.is_empty() || !types.is_empty() {
|
||||
needs.insert(Need::Dom);
|
||||
}
|
||||
Report {
|
||||
kind,
|
||||
needs: needs.into_iter().collect(),
|
||||
lines,
|
||||
targets: self.targets,
|
||||
directives: self.directives,
|
||||
includes: self.includes,
|
||||
features,
|
||||
members,
|
||||
types,
|
||||
menu_strings,
|
||||
menu_dynamic: self.menu_dynamic,
|
||||
outside,
|
||||
problems: self.problems,
|
||||
truncated: self.truncated,
|
||||
steps: self.steps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` cut to at most `max` characters, the last one `…` when it was cut.
|
||||
pub(crate) fn echo(s: &str, max: usize) -> String {
|
||||
match s.char_indices().nth(max) {
|
||||
None => s.to_string(),
|
||||
Some(_) => {
|
||||
let keep = s.char_indices().nth(max.saturating_sub(1)).map_or(0, |(i, _)| i);
|
||||
let mut out = s.get(..keep).unwrap_or_default().to_string();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -83,6 +83,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
base *= aff(g.transform());
|
||||
top = g;
|
||||
}
|
||||
if let Some(boards) = inkscape_pages(&xml, base) {
|
||||
im.doc.artboards = boards;
|
||||
}
|
||||
|
||||
// Top-level `<g id>` elements become layers (as in the reference app), named from their
|
||||
// labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on
|
||||
@@ -245,6 +248,70 @@ impl RootUnits {
|
||||
}
|
||||
}
|
||||
|
||||
/// `'id' (feA, feB)` for each filter: its id and the primitives it uses, for warnings.
|
||||
fn filter_names(filters: &[Arc<usvg::filter::Filter>]) -> String {
|
||||
use usvg::filter::Kind as K;
|
||||
let names: Vec<String> = filters
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mut kinds: Vec<&str> = Vec::new();
|
||||
for p in f.primitives() {
|
||||
let k = match p.kind() {
|
||||
K::Blend(_) => "feBlend",
|
||||
K::ColorMatrix(_) => "feColorMatrix",
|
||||
K::ComponentTransfer(_) => "feComponentTransfer",
|
||||
K::Composite(_) => "feComposite",
|
||||
K::ConvolveMatrix(_) => "feConvolveMatrix",
|
||||
K::DiffuseLighting(_) => "feDiffuseLighting",
|
||||
K::DisplacementMap(_) => "feDisplacementMap",
|
||||
K::DropShadow(_) => "feDropShadow",
|
||||
K::Flood(_) => "feFlood",
|
||||
K::GaussianBlur(_) => "feGaussianBlur",
|
||||
K::Image(_) => "feImage",
|
||||
K::Merge(_) => "feMerge",
|
||||
K::Morphology(_) => "feMorphology",
|
||||
K::Offset(_) => "feOffset",
|
||||
K::SpecularLighting(_) => "feSpecularLighting",
|
||||
K::Tile(_) => "feTile",
|
||||
K::Turbulence(_) => "feTurbulence",
|
||||
};
|
||||
if !kinds.contains(&k) {
|
||||
kinds.push(k);
|
||||
}
|
||||
}
|
||||
let id = if f.id().is_empty() || made_up(f.id()) { String::new() } else { format!("'{}' ", f.id()) };
|
||||
format!("{id}({})", kinds.join(", "))
|
||||
})
|
||||
.collect();
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
/// Inkscape's pages (`<inkscape:page>` in its `<sodipodi:namedview>`, in user units) as artboards
|
||||
/// through `user` (user units → document points), named by their labels; `None` without any.
|
||||
fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option<Vec<vectorcraft_doc::Artboard>> {
|
||||
let num = |n: roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let pages: Vec<vectorcraft_doc::Artboard> = xml
|
||||
.descendants()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "page" && n.tag_name().namespace() == Some(INKSCAPE))
|
||||
.filter_map(|p| {
|
||||
let (w, h) = (num(p, "width")?, num(p, "height")?);
|
||||
let (x, y) = (num(p, "x").unwrap_or(0.0), num(p, "y").unwrap_or(0.0));
|
||||
// A box too large to add up or to place (1e308 wide) is no page.
|
||||
let r = user.transform_rect_bbox(Rect::new(x, y, x + w, y + h));
|
||||
(w > 0.0 && h > 0.0 && r.is_finite()).then_some((p, r))
|
||||
})
|
||||
.take(1000)
|
||||
.enumerate()
|
||||
.map(|(i, (p, r))| vectorcraft_doc::Artboard {
|
||||
id: i as u32 + 1,
|
||||
name: p.attribute((INKSCAPE, "label")).filter(|l| !l.trim().is_empty()).map_or_else(|| format!("Artboard {}", i + 1), str::to_string),
|
||||
rect: r,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
})
|
||||
.collect();
|
||||
(!pages.is_empty()).then_some(pages)
|
||||
}
|
||||
/// How many clip paths deep (`<clipPath clip-path>`) an object's clip is read.
|
||||
const MAX_CLIP_NEST: usize = 16;
|
||||
|
||||
@@ -902,7 +969,19 @@ impl Importer {
|
||||
let effects = match g.filters() {
|
||||
[] => vec![],
|
||||
filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| {
|
||||
self.warn(format!("filter on {label} ignored"));
|
||||
// usvg wraps a filtered element in a group of its own: name the element then.
|
||||
let on = match g.children() {
|
||||
[usvg::Node::Group(_)] | [] => label.clone(),
|
||||
[one] if made_up(g.id()) || g.id().is_empty() => {
|
||||
if one.id().is_empty() || made_up(one.id()) {
|
||||
"an object".to_string()
|
||||
} else {
|
||||
format!("'{}'", one.id())
|
||||
}
|
||||
}
|
||||
_ => label.clone(),
|
||||
};
|
||||
self.warn(format!("filter {} on {on} isn't supported; drawn without it", filter_names(filters)));
|
||||
vec![]
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -216,3 +216,44 @@ fn percentages_in_a_symbol_are_of_its_view_box() {
|
||||
};
|
||||
assert_similar(&drawing("100%", "100%", "25%", "50%"), &drawing("100", "50", "25", "25"), 24.0, 0.001);
|
||||
}
|
||||
|
||||
/// #1598: an unsupported filter's warning names the filter, its primitives and the object, and
|
||||
/// says it is drawn without it (not "a group": usvg wraps the element in one).
|
||||
#[test]
|
||||
fn unsupported_filters_are_named_in_the_warning() {
|
||||
let body = r#"<filter id="f"><feColorMatrix type="saturate" values="0"/></filter><filter id="t"><feTurbulence baseFrequency="0.05"/></filter>
|
||||
<rect x="20" y="20" width="60" height="60" fill="red" filter="url(#f)"/>
|
||||
<rect id="r" x="100" y="20" width="60" height="60" fill="blue" filter="url(#t)"/>"#;
|
||||
let (d, w) = import_with_report(&svg(body)).unwrap();
|
||||
assert!(w.iter().any(|w| w == "filter 'f' (feColorMatrix) on an object isn't supported; drawn without it"), "{w:?}");
|
||||
assert!(w.iter().any(|w| w == "filter 't' (feTurbulence) on 'r' isn't supported; drawn without it"), "{w:?}");
|
||||
assert_eq!(art(&d).iter().filter(|n| n.path_data().is_some()).count(), 2, "both drawn");
|
||||
}
|
||||
|
||||
/// #1601: Inkscape's pages become artboards, at their places (through the viewBox) and with their
|
||||
/// labels.
|
||||
#[test]
|
||||
fn inkscape_pages_become_artboards() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" width="200" height="200" viewBox="0 0 100 100">
|
||||
<sodipodi:namedview><inkscape:page x="0" y="0" width="100" height="100" inkscape:label="Front"/><inkscape:page x="120" y="0" width="60" height="150"/><inkscape:page x="0" y="0" width="0" height="10"/></sodipodi:namedview>
|
||||
<rect x="10" y="10" width="80" height="80" fill="red"/></svg>"#;
|
||||
let d = import_with(svg, &ImportOptions::default()).unwrap().0;
|
||||
let b: Vec<(String, Rect)> = d.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
|
||||
assert_eq!(b, vec![("Front".to_string(), Rect::new(0.0, 0.0, 200.0, 200.0)), ("Artboard 2".to_string(), Rect::new(240.0, 0.0, 360.0, 300.0))]);
|
||||
// Hostile boxes (too large to add up, not numbers, negative) are no pages, and at most 1,000 are read.
|
||||
let pages = r#"<inkscape:page x="1e308" y="0" width="1e308" height="10"/><inkscape:page x="NaN" y="0" width="10" height="10"/><inkscape:page width="inf" height="10"/><inkscape:page width="-5" height="10"/>"#.to_string()
|
||||
+ &r#"<inkscape:page width="10" height="10"/>"#.repeat(1500);
|
||||
let hostile = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="50" height="50"><g>{pages}</g></svg>"#
|
||||
);
|
||||
let d = import_with(&hostile, &ImportOptions::default()).unwrap().0;
|
||||
assert_eq!(d.artboards.len(), 1000);
|
||||
assert!(d.artboards.iter().all(|a| a.rect == Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", d.artboards.first());
|
||||
// A file without pages keeps its one artboard.
|
||||
let d = import_with(&svg_doc(), &ImportOptions::default()).unwrap().0;
|
||||
assert_eq!(d.artboards.len(), 1);
|
||||
}
|
||||
|
||||
fn svg_doc() -> String {
|
||||
svg(r#"<rect x="10" y="10" width="20" height="20"/>"#)
|
||||
}
|
||||
@@ -41,8 +41,19 @@ pub struct OtFeatures {
|
||||
pub ordinals: bool,
|
||||
/// Swashes (`swsh`).
|
||||
pub swash: bool,
|
||||
/// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic
|
||||
/// sets, alternates, titling, proportional and lining figures, superscript and subscript.
|
||||
pub extra: u32,
|
||||
}
|
||||
|
||||
/// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]:
|
||||
/// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum`
|
||||
/// and lining `lnum` figures, superscript `sups` and subscript `subs`.
|
||||
pub const EXTRA_FEATURES: [&str; 26] = [
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18",
|
||||
"ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs",
|
||||
];
|
||||
|
||||
impl Default for OtFeatures {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -56,6 +67,7 @@ impl Default for OtFeatures {
|
||||
tabular_figures: false,
|
||||
ordinals: false,
|
||||
swash: false,
|
||||
extra: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,13 +101,14 @@ impl OtFeatures {
|
||||
v.push(if on { tag.to_string() } else { format!("-{tag}") });
|
||||
}
|
||||
}
|
||||
v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string()));
|
||||
v
|
||||
}
|
||||
|
||||
/// Is `tag` (optionally prefixed with `-` or `+`) one this set understands?
|
||||
pub fn known_tag(tag: &str) -> bool {
|
||||
let t = tag.trim_start_matches(['-', '+']);
|
||||
matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh")
|
||||
matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t)
|
||||
}
|
||||
|
||||
/// This set with `tags` applied on top.
|
||||
@@ -116,7 +129,15 @@ impl OtFeatures {
|
||||
"tnum" => o.tabular_figures = on,
|
||||
"ordn" => o.ordinals = on,
|
||||
"swsh" => o.swash = on,
|
||||
_ => {}
|
||||
_ => {
|
||||
if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) {
|
||||
if on {
|
||||
o.extra |= 1 << i;
|
||||
} else {
|
||||
o.extra &= !(1 << i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
@@ -155,6 +176,11 @@ impl OtFeatures {
|
||||
v.push(f(tag, true));
|
||||
}
|
||||
}
|
||||
for (i, tag) in EXTRA_FEATURES.iter().enumerate() {
|
||||
if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) {
|
||||
v.push(f(&t, true));
|
||||
}
|
||||
}
|
||||
if self.vertical {
|
||||
// Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin
|
||||
// and digits itself (they would lie upside down, and tate-chu-yoko break).
|
||||
@@ -230,6 +256,22 @@ pub fn explicit_ligatures<S: AsRef<str>>(features: &[S]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1607: stylistic sets, alternates, titling, figure styles and superscript/subscript are
|
||||
/// known, round-trip through the stored tags and reach the shaper.
|
||||
#[test]
|
||||
fn extra_features_are_kept_and_shaped() {
|
||||
for t in ["ss01", "ss20", "-ss07", "salt", "titl", "pnum", "lnum", "sups", "subs"] {
|
||||
assert!(OtFeatures::known_tag(t), "{t}");
|
||||
}
|
||||
assert!(!OtFeatures::known_tag("ss21"));
|
||||
let o = OtFeatures::from_tags(["ss03", "pnum", "sups", "-liga"]);
|
||||
assert_eq!(o.to_tags(), vec!["-liga", "ss03", "pnum", "sups"]);
|
||||
assert_eq!(OtFeatures::from_tags(["ss03", "-ss03"]).to_tags(), Vec::<String>::new(), "turned off again");
|
||||
let st = CharStyle { features: vec!["ss05".into(), "titl".into()], ..CharStyle::default() };
|
||||
let tags: Vec<String> = OtFeatures::default().resolve(&st).iter().filter(|f| f.value == 1).map(|f| f.tag.to_string()).collect();
|
||||
assert!(tags.contains(&"ss05".to_string()) && tags.contains(&"titl".to_string()), "{tags:?}");
|
||||
}
|
||||
use crate::{FontDb, layout};
|
||||
use vectorcraft_doc::TextObject;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
@@ -1013,6 +1013,8 @@ struct Pen<'r> {
|
||||
/// Leading measured from em box top to em box top: where the next line's em box top goes.
|
||||
model: LeadingModel,
|
||||
next_top: Option<f64>,
|
||||
/// The narrowest word no line from here down is wide enough for (see [`Pen::place`]).
|
||||
no_room_for: f64,
|
||||
}
|
||||
|
||||
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
|
||||
@@ -1020,10 +1022,27 @@ struct Pen<'r> {
|
||||
const MAX_STEPS: usize = 100_000;
|
||||
|
||||
impl Pen<'_> {
|
||||
/// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` =
|
||||
/// the frame is full (overflow).
|
||||
/// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts
|
||||
/// with a word `word` wide; `None` = the frame is full (overflow).
|
||||
/// In a frame that isn't a rectangle, lines too short for the word are left empty and it moves
|
||||
/// down to the first one wide enough. Only when no line is does it go on the first line with
|
||||
/// room for any text, and get broken there.
|
||||
/// The `bool` is true for a further span at the previous line's baseline.
|
||||
fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64, bool)> {
|
||||
fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64, word: f64) -> Option<(f64, f64, f64, bool)> {
|
||||
if word > 0.0 && word < self.no_room_for {
|
||||
let mut pen = self.clone();
|
||||
if let Some(placed) = pen.place_at_least(est, ind_l, ind_r, word) {
|
||||
*self = pen;
|
||||
return Some(placed);
|
||||
}
|
||||
// Lines further down are no wider for this word: don't search for it, or a wider one, again.
|
||||
self.no_room_for = word;
|
||||
}
|
||||
self.place_at_least(est, ind_l, ind_r, 0.0)
|
||||
}
|
||||
|
||||
/// [`Pen::place`], skipping lines of a non-rectangular frame narrower than `min`.
|
||||
fn place_at_least(&mut self, est: Metrics, ind_l: f64, ind_r: f64, min: f64) -> Option<(f64, f64, f64, bool)> {
|
||||
let Some(regions) = self.regions else {
|
||||
let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est));
|
||||
return Some((b, f64::NEG_INFINITY, f64::INFINITY, false));
|
||||
@@ -1046,7 +1065,12 @@ impl Pen<'_> {
|
||||
if baseline + est.desc > r.bottom() + 0.01 {
|
||||
break;
|
||||
}
|
||||
let fits = |&(a, b): &(f64, f64)| b - a - ind_l - ind_r > est.asc.max(1.0);
|
||||
// A rectangle's lines are all as wide: none below has more room for the word.
|
||||
let min = if r.rect || r.polys.is_empty() { 0.0 } else { min };
|
||||
let fits = |&(a, b): &(f64, f64)| {
|
||||
let room = b - a - ind_l - ind_r;
|
||||
room > est.asc.max(1.0) && room + EPS >= min
|
||||
};
|
||||
if r.wraps.is_empty() {
|
||||
match r.span(baseline - est.asc, baseline + est.desc) {
|
||||
Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)),
|
||||
@@ -1114,6 +1138,25 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
|
||||
(at - rel).max(0.0)
|
||||
}
|
||||
|
||||
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
|
||||
/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it.
|
||||
fn word_width(g: &[SGlyph], i: usize) -> f64 {
|
||||
let mut w = 0.0;
|
||||
for gl in g.get(i..).unwrap_or_default() {
|
||||
if gl.is_space() {
|
||||
break;
|
||||
}
|
||||
if gl.is_soft_hyphen() {
|
||||
return w + hyphen_glyph(gl).adv;
|
||||
}
|
||||
w += gl.adv;
|
||||
if gl.break_after() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
|
||||
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
|
||||
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) {
|
||||
@@ -1450,7 +1493,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
|
||||
while widths.len() < sg.len() {
|
||||
let first = widths.is_empty();
|
||||
let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 };
|
||||
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent) else { break };
|
||||
// Only the first line's first word is known here. Later lines are placed as the composed
|
||||
// lines need: each fits its width, so its first word does too.
|
||||
let word = if first { word_width(sg, 0) } else { 0.0 };
|
||||
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break };
|
||||
let w = x1 - x0 - ind_l - para.right_indent;
|
||||
widths.push(w);
|
||||
acc += w.max(1.0);
|
||||
@@ -1492,6 +1538,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
queued: vec![],
|
||||
model: t.para_at(0).leading_model,
|
||||
next_top: None,
|
||||
no_room_for: f64::INFINITY,
|
||||
};
|
||||
'paras: for (pi, pr) in paras.iter().enumerate() {
|
||||
let para = t.para_at(pi);
|
||||
@@ -1527,6 +1574,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
|
||||
let first_line = li_para == 0;
|
||||
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
|
||||
// Measured before Mojikumi narrows an opening bracket, as the composer measures it.
|
||||
let word = word_width(&sg, i);
|
||||
// Mojikumi: an opening bracket starting a line is set flush with the line's start (the
|
||||
// space before it goes), and the line has that much more room. At a paragraph's start
|
||||
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
|
||||
@@ -1543,7 +1592,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
// next row/column if it no longer fits).
|
||||
let (baseline, x0, x1, end, hyph, m) = loop {
|
||||
let first_in_region = pen.prev.is_none();
|
||||
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent) else {
|
||||
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent, word) else {
|
||||
cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start };
|
||||
break 'paras;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ pub mod test_fonts;
|
||||
pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
|
||||
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
|
||||
pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir,
|
||||
is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs,
|
||||
|
||||
@@ -212,6 +212,38 @@ fn area_flow_inside_circle_keeps_glyphs_inside() {
|
||||
assert!(w(l.lines.len() / 2) > w(0));
|
||||
}
|
||||
|
||||
/// In a frame that is narrow at the top, a word that doesn't fit the top lines moves down to the
|
||||
/// first line wide enough for it (the lines above stay empty), with either composer. Only a word
|
||||
/// wider than every line is broken.
|
||||
#[test]
|
||||
fn area_flow_moves_a_word_down_to_a_line_wide_enough_for_it() {
|
||||
let triangle = |base: f64, height: f64| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((base / 2.0, 0.0));
|
||||
p.line_to((base, height));
|
||||
p.line_to((0.0, height));
|
||||
p.close_path();
|
||||
p
|
||||
};
|
||||
let text = "Wonderful extraordinary typesetting";
|
||||
for composer in [Composer::EveryLine, Composer::SingleLine] {
|
||||
let mut t = area_path(text, style(12.0), &triangle(200.0, 200.0), Justify::Left);
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow, "{composer:?}");
|
||||
let lines: Vec<&str> = l.lines.iter().map(|li| text[li.start..li.end].trim_end()).collect();
|
||||
// The top lines are wide enough for "Wo" but not for "Wonderful": it starts lower.
|
||||
assert_eq!(lines.first(), Some(&"Wonderful"), "{composer:?}: {lines:?}");
|
||||
for li in &lines {
|
||||
assert!(li.split(' ').all(|w| text.split(' ').any(|word| word == w)), "{composer:?}: {lines:?}");
|
||||
}
|
||||
}
|
||||
// No line of this triangle is as wide as the word: it is still broken, and every letter is set.
|
||||
let word = "Supercalifragilisticexpialidocious";
|
||||
let l = layout(db(), &area_path(word, style(12.0), &triangle(60.0, 400.0), Justify::Left));
|
||||
assert!(!l.overflow && l.lines.len() > 1, "{:?}", line_places(&l));
|
||||
}
|
||||
|
||||
/// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or
|
||||
/// takes more than 20 s.
|
||||
fn layout_soon(t: &TextObject) -> TextLayout {
|
||||
|
||||
@@ -440,6 +440,9 @@ impl Targets {
|
||||
if let Some((q, k)) = self.nearest_point(p, tol) {
|
||||
return (q, self.label(q, k.label()).into_iter().collect());
|
||||
}
|
||||
if let Some(q) = self.crossing(p, tol) {
|
||||
return (q, self.label(q, "intersect").into_iter().collect());
|
||||
}
|
||||
if let Some(q) = self.on_segment(p, tol) {
|
||||
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
|
||||
}
|
||||
@@ -545,6 +548,43 @@ impl Targets {
|
||||
.map(|(c, n)| c.eval(n.t))
|
||||
}
|
||||
|
||||
/// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing
|
||||
/// nearest `p` within `tol` ("intersect", #1602).
|
||||
fn crossing(&self, p: Point, tol: f64) -> Option<Point> {
|
||||
// A ruler guide running past, as a short line through the reach of `p`.
|
||||
let ruler = |r: &Ruler| {
|
||||
let (pos, ..) = r.line_at(p, tol)?;
|
||||
let (across, along) = if r.vertical { (p.x, p.y) } else { (p.y, p.x) };
|
||||
let (a, b) = (along - tol, along + tol);
|
||||
((pos - across).abs() <= tol)
|
||||
.then(|| PathSeg::Line(if r.vertical { Line::new((pos, a), (pos, b)) } else { Line::new((a, pos), (b, pos)) }))
|
||||
};
|
||||
let near: Vec<PathSeg> = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol)
|
||||
.map(|(_, c)| *c)
|
||||
.chain(self.rulers.iter().filter_map(ruler))
|
||||
// shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter
|
||||
.take(32)
|
||||
.collect();
|
||||
let mut best: Option<Point> = None;
|
||||
for (i, a) in near.iter().enumerate() {
|
||||
for b in near.iter().skip(i + 1) {
|
||||
let hits: Vec<Point> = match (a, b) {
|
||||
(PathSeg::Line(l), c) | (c, PathSeg::Line(l)) => c.intersect_line(*l).iter().map(|x| l.eval(x.line_t)).collect(),
|
||||
_ => meeting(a, b, p).into_iter().collect(),
|
||||
};
|
||||
for q in hits {
|
||||
if q.distance(p) <= tol && best.is_none_or(|b| q.distance(p) < b.distance(p)) {
|
||||
best = Some(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line
|
||||
/// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there.
|
||||
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
|
||||
@@ -828,6 +868,20 @@ impl Targets {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back
|
||||
/// settles where they meet (none when they don't nearby).
|
||||
fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option<Point> {
|
||||
let mut q = p;
|
||||
for _ in 0..32 {
|
||||
let on_a = a.eval(a.nearest(q, 1e-9).t);
|
||||
q = b.eval(b.nearest(on_a, 1e-9).t);
|
||||
if on_a.distance(q) <= 1e-6 {
|
||||
return Some(q);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on.
|
||||
pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<Overlay>) {
|
||||
snap_with(cx, p, || Targets::collect(cx.doc, exclude, None))
|
||||
@@ -1380,6 +1434,43 @@ mod tests {
|
||||
assert_eq!(q, Point::new(340.0, 340.0));
|
||||
}
|
||||
|
||||
/// #1602: a drawn point lands where two paths cross, where a path crosses a ruler guide and
|
||||
/// where two ruler guides cross, labelled "intersect"; an anchor nearby still wins.
|
||||
#[test]
|
||||
fn drawn_points_land_on_intersections() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::shapes;
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
// A slanted line through the rect's top side (y = 100) at x = 150, and a circle across its
|
||||
// right side (x = 200).
|
||||
for path in [shapes::line(Point::new(145.0, 90.0), Point::new(165.0, 130.0)), shapes::ellipse(Rect::new(175.0, 130.0, 215.0, 170.0))] {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap();
|
||||
}
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 400.0));
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 300.0));
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 150.0));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = ToolContext { guides: true, ..cx(&d, &s, &p) };
|
||||
let t = Targets::for_drawing(&d, &[], None).styled(&c).with_rulers(&c);
|
||||
let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0);
|
||||
assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}");
|
||||
assert_eq!(labels(&ov), ["intersect"]);
|
||||
// The circle (curves, centre (195, 150), radius 20) crosses the rect's right side.
|
||||
let (q, ov) = t.snap_from(Point::new(201.5, 131.5), None, 4.0);
|
||||
assert!((q - Point::new(200.0, 150.0 - 375f64.sqrt())).hypot() < 0.02, "{q:?}");
|
||||
assert_eq!(labels(&ov), ["intersect"]);
|
||||
// A ruler guide across a path, and two ruler guides.
|
||||
let (q, ov) = t.snap_from(Point::new(101.0, 151.0), None, 4.0);
|
||||
assert_eq!((q, labels(&ov)), (Point::new(100.0, 150.0), vec!["intersect"]));
|
||||
let (q, ov) = t.snap_from(Point::new(402.0, 298.0), None, 4.0);
|
||||
assert_eq!((q, labels(&ov)), (Point::new(400.0, 300.0), vec!["intersect"]));
|
||||
// An anchor in reach wins, and a path alone is still "path".
|
||||
assert_eq!(labels(&t.snap_from(Point::new(146.0, 91.0), None, 4.0).1), ["anchor"]);
|
||||
assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]);
|
||||
}
|
||||
|
||||
/// The angle sets of Preferences › Smart Guides › Construction Guides.
|
||||
#[test]
|
||||
fn construction_angle_sets() {
|
||||
|
||||
@@ -166,6 +166,61 @@ pub(crate) fn auto_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) ->
|
||||
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
|
||||
}
|
||||
|
||||
/// How far apart (in RGB) two tones of one colour may be: a scan's paper is a tenth darker in one
|
||||
/// corner than in another.
|
||||
const TONES_APART: f64 = 60.0;
|
||||
|
||||
/// `palette` with the tones of one colour made one colour. Two flat colours leave nothing between
|
||||
/// them but the pixels of their shared edges; where a scan's paper is unevenly lit, or a wash
|
||||
/// shades off, the pixels between two of its tones are as many as those at either: such a pair is
|
||||
/// one colour (their mean, by pixels), however many tones of it were found.
|
||||
pub(crate) fn fold_tones(pix: &[Px], mut palette: Vec<[u8; 3]>) -> Vec<[u8; 3]> {
|
||||
let apart = |a: [u8; 3], b: [u8; 3]| dist2(a.map(f64::from), b.map(f64::from)).sqrt();
|
||||
loop {
|
||||
let close: Vec<(usize, usize)> = (0..palette.len())
|
||||
.flat_map(|i| (i + 1..palette.len()).map(move |j| (i, j)))
|
||||
.filter(|&(i, j)| palette.get(i).zip(palette.get(j)).is_some_and(|(a, b)| apart(*a, *b) <= TONES_APART))
|
||||
.collect();
|
||||
if close.is_empty() {
|
||||
return palette;
|
||||
}
|
||||
// Each opaque pixel goes to its nearest colour; for a close pair, the pixels of either are
|
||||
// counted by where they lie from one to the other: at one, between, at the other.
|
||||
let mut thirds = vec![[0usize; 3]; close.len()];
|
||||
for p in pix.iter().filter(|p| p[3] > 0.9) {
|
||||
let c = rgb255(p);
|
||||
let Some(near) = (0..palette.len()).min_by(|&i, &j| dist2(c, palette[i].map(f64::from)).total_cmp(&dist2(c, palette[j].map(f64::from))))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (k, &(i, j)) in close.iter().enumerate() {
|
||||
if near == i || near == j {
|
||||
let (a, b) = (palette[i].map(f64::from), palette[j].map(f64::from));
|
||||
let t = (0..3).map(|ch| (c[ch] - a[ch]) * (b[ch] - a[ch])).sum::<f64>() / dist2(a, b).max(1e-9);
|
||||
thirds[k][if t < 1.0 / 3.0 {
|
||||
0
|
||||
} else if t <= 2.0 / 3.0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pair with the most between it, if that is no fewer than a third of what the lesser
|
||||
// end has.
|
||||
let filled = |t: &[usize; 3]| t[1] as f64 / (t[0].min(t[2]).max(1)) as f64;
|
||||
let Some((k, _)) = thirds.iter().enumerate().filter(|(_, t)| filled(t) >= 1.0 / 3.0).max_by(|a, b| filled(a.1).total_cmp(&filled(b.1)))
|
||||
else {
|
||||
return palette;
|
||||
};
|
||||
let ((i, j), t) = (close[k], thirds[k]);
|
||||
let (na, nb) = ((t[0] + t[1] / 2).max(1) as f64, (t[2] + t[1] / 2).max(1) as f64);
|
||||
palette[i] = [0, 1, 2].map(|ch| ((f64::from(palette[i][ch]) * na + f64::from(palette[j][ch]) * nb) / (na + nb)).round() as u8);
|
||||
palette.remove(j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Colours the current palette cannot explain. `residual` is each pixel's error after the best blend
|
||||
/// of the known classes (see `unmix`); a solid run of pixels (a pixel and its four neighbours agree)
|
||||
/// with a large error is a colour that is missing, such as the small accent that was too few
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::field::{blur_plane, upsample};
|
||||
use super::geom::{Cubic, finite};
|
||||
use super::march::{area2, contours};
|
||||
use super::outline::{Stats, Style, fit_outline};
|
||||
use super::palette::{WHITE_MIN, auto_palette, flat_fraction, parse_hex, unexplained_colours};
|
||||
use super::palette::{WHITE_MIN, auto_palette, flat_fraction, fold_tones, parse_hex, unexplained_colours};
|
||||
use super::restore::RestoreParams;
|
||||
use super::unmix::{Px, count_ambiguous, unmix};
|
||||
use crate::{Raster, TraceError, TraceParams, TraceResult, TracedPath};
|
||||
@@ -317,11 +317,15 @@ fn distance(a: [u8; 3], b: [u8; 3]) -> f64 {
|
||||
(0..3).map(|k| (f64::from(a[k]) - f64::from(b[k])).powi(2)).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
type Colours = Vec<[u8; 3]>;
|
||||
|
||||
/// The colours to trace: those the caller named (white is always its own class so it is skipped;
|
||||
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found.
|
||||
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<Vec<[u8; 3]>, TraceError> {
|
||||
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found,
|
||||
/// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding.
|
||||
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> {
|
||||
if params.logo_colors.is_empty() {
|
||||
return Ok(auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS)));
|
||||
let found = auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS));
|
||||
return Ok((fold_tones(pix, found.clone()), found));
|
||||
}
|
||||
let mut palette: Vec<[u8; 3]> = Vec::new();
|
||||
for s in ¶ms.logo_colors {
|
||||
@@ -332,7 +336,7 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul
|
||||
palette.push(c);
|
||||
}
|
||||
}
|
||||
Ok(palette)
|
||||
Ok((palette.clone(), palette))
|
||||
}
|
||||
|
||||
/// Trace a flat-colour logo.
|
||||
@@ -342,11 +346,13 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
return Ok(TraceResult::default());
|
||||
}
|
||||
let (mut pix, mut w, mut h, mut factor) = working_copy(img, 1);
|
||||
let mut palette = choose_palette(params, &pix, w, h)?;
|
||||
// (`tones`: the colours as found, each tone of an unevenly lit colour on its own. Whether the
|
||||
// image is flat art is asked of them: a paper's tones are flat colour too.)
|
||||
let (mut palette, tones) = choose_palette(params, &pix, w, h)?;
|
||||
if palette.is_empty() {
|
||||
return Ok(TraceResult::default());
|
||||
}
|
||||
let fraction = flat_fraction(&pix, w, h, &palette);
|
||||
let fraction = flat_fraction(&pix, w, h, &tones);
|
||||
if fraction < MIN_FLAT {
|
||||
return Err(TraceError::NotFlat { fraction });
|
||||
}
|
||||
@@ -366,6 +372,8 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
planes
|
||||
} else {
|
||||
palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len()));
|
||||
// (A colour nothing explained may be one more tone of a colour that is there.)
|
||||
palette = fold_tones(&pix, palette);
|
||||
classes = classes_for(&palette);
|
||||
unmix(&pix, w, h, &classes).0
|
||||
}
|
||||
|
||||
@@ -413,3 +413,17 @@ fn random_small_images_and_settings_never_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scan's paper is darker in one corner than in another: it is still one colour, not a band of
|
||||
/// paths for each tone of it.
|
||||
#[test]
|
||||
fn uneven_paper_is_one_colour() {
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
let shade = 1.0 - 0.1 * f64::from(x) / 160.0 - 0.04 * (f64::from(y) / 40.0).sin().abs();
|
||||
let ink = (40..120).contains(&x) && (30..90).contains(&y);
|
||||
let c = if ink { [40.0, 60.0, 150.0] } else { [236.0, 230.0, 214.0] };
|
||||
[(c[0] * shade) as u8, (c[1] * shade) as u8, (c[2] * shade) as u8, 255]
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
assert_eq!(res.paths.len(), 2, "{:?}", res.paths.iter().map(|p| (p.color, p.pixels)).collect::<Vec<_>>());
|
||||
}
|
||||
@@ -78,6 +78,7 @@ pub(crate) mod save_options;
|
||||
mod save_pdf;
|
||||
pub mod save_style_library;
|
||||
pub mod save_swatch_library;
|
||||
pub mod script_check;
|
||||
mod shapes;
|
||||
pub mod slices;
|
||||
pub mod spot_colors;
|
||||
@@ -285,6 +286,7 @@ registry! {
|
||||
PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC,
|
||||
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
|
||||
Plugin: [plugin::KIND] => plugin::SPEC,
|
||||
ScriptCheck: [script_check::KIND] => script_check::SPEC,
|
||||
VectorHalftone: [halftone::KIND] => halftone::SPEC,
|
||||
PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC,
|
||||
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! File › Scripts › Check Script…: the report `script.check` gives on a script written for the
|
||||
//! reference app, read without running it: what kind of file it is and what it needs, what it does
|
||||
//! outside the document, the menu commands it runs, the document-model members it uses, its
|
||||
//! directives and includes, and its problems, with Copy Report (the report as JSON) and Close.
|
||||
//! `ui.checkScript {path? | data? | dataBase64?, name?}` opens it; a path also checks the files
|
||||
//! the script's `#include` lines name. Without a file it asks for one (the web opens the browser's file
|
||||
//! picker), and File › Open of a `.jsx`, `.jsxinc` or `.jsxbin` file opens it too.
|
||||
//!
|
||||
//! Fields: `report` (`script.check`'s result) and `discard` (set it and confirm for Copy Report;
|
||||
//! confirming copies the report either way).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{FilePick, VectorcraftApp, io, widgets};
|
||||
|
||||
/// The dialog kind of a script's report.
|
||||
pub const KIND: &str = "scriptCheck";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Check Script").into(),
|
||||
body,
|
||||
confirm,
|
||||
// Close, with Copy Report beside it.
|
||||
ok: None,
|
||||
discard: Some("Copy Report"),
|
||||
min_width: 460.0,
|
||||
max_width: Some(560.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// `ui.checkScript`: check the script at `path` (and the files it includes) or given as `data` or
|
||||
/// `dataBase64`, and show the report; without any of them, pick a `.jsx`, `.jsxinc` or `.jsxbin`
|
||||
/// file (the web opens File › Open's picker, and [`io::open_bytes`] shows the report).
|
||||
pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let mut params = p.clone();
|
||||
if ["path", "data", "dataBase64"].iter().all(|k| p.get(k).is_none()) {
|
||||
if app.services.open_async.is_some() {
|
||||
return io::open_dialog(app).map(|_| Value::Null);
|
||||
}
|
||||
let pick = FilePick { filters: vec![("Scripts", vectorcraft_engine::cmd::scripts::EXTS)], ..Default::default() };
|
||||
params = json!({ "path": crate::picks::open(app, &pick).ok_or("cancelled")? });
|
||||
}
|
||||
if params.get("path").is_some()
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
o.entry("includes").or_insert(json!(true));
|
||||
}
|
||||
let r = app.run("script.check", params)?;
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "report": r })));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Copy Report: the report as JSON on the clipboard. The dialog stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let report = d.fields.get("report").cloned().unwrap_or(Value::Null);
|
||||
app.clipboard_out = Some(serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
|
||||
if let Some(open) = app.ui.dialog.as_mut().filter(|open| open.kind == KIND) {
|
||||
open.fields.remove("discard");
|
||||
}
|
||||
app.status("Copied the script report");
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// What need `id` means for the person reading the report.
|
||||
pub(crate) fn need_label(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"language" => tl!("Needs the script language's own objects or directives"),
|
||||
"dom" => tl!("Needs the document model"),
|
||||
"files" => tl!("Needs files"),
|
||||
"menuCommands" => tl!("Needs menu commands"),
|
||||
"dialogs" => tl!("Needs dialogs"),
|
||||
"hostOnly" => tl!("Needs features only the reference app has"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn outside_label(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"filesWritten" => tl!("Writes, moves or deletes files"),
|
||||
"filesOpened" => tl!("Opens files in other apps"),
|
||||
"network" => tl!("Opens network connections"),
|
||||
"nativeCode" => tl!("Loads native code"),
|
||||
"otherApps" => tl!("Sends messages to other apps"),
|
||||
"environment" => tl!("Reads or sets environment variables"),
|
||||
"unseenCode" => tl!("Runs code the checker can't read"),
|
||||
"preferences" => tl!("Changes the reference app's preferences"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn status_label(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"checked" => tl!("Checked"),
|
||||
"missing" => tl!("Not found"),
|
||||
"notFollowed" => tl!("Not followed: outside the script's folder"),
|
||||
"cycle" => tl!("Includes itself"),
|
||||
"notRead" => tl!("Not read"),
|
||||
"limit" => tl!("Not read: a limit was reached"),
|
||||
"unreadable" => tl!("Compiled or not text"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn problem_label(code: &str) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
"unterminatedString" => tl!("String not closed"),
|
||||
"unterminatedComment" => tl!("Comment not closed"),
|
||||
"unterminatedRegExp" => tl!("Regular expression not closed"),
|
||||
"unterminatedTemplate" => tl!("Template literal not closed"),
|
||||
"unterminatedXml" => tl!("XML literal not closed"),
|
||||
"unclosedBracket" => tl!("Bracket never closed"),
|
||||
"unexpectedBracket" => tl!("Closing bracket with no opening one"),
|
||||
"newerSyntax" => tl!("Syntax newer than ES3"),
|
||||
"unknownDirective" => tl!("Unknown directive"),
|
||||
"notUtf8" => tl!("Not valid UTF-8: unreadable bytes were replaced"),
|
||||
"mixedLineEndings" => tl!("Mixed line endings"),
|
||||
"tooDeep" => tl!("Nesting too deep to follow"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where something is: `line 12`, or `lib/a.jsxinc, line 3` in an included file.
|
||||
fn place(item: &Value) -> String {
|
||||
let line = item["line"].as_u64().map(|l| l.to_string());
|
||||
match (item["file"].as_str(), line) {
|
||||
(Some(file), Some(line)) => crate::i18n::fmt(tl!("{file}, line {line}"), &[("file", file), ("line", &line)]),
|
||||
(Some(file), None) => file.to_string(),
|
||||
(None, Some(line)) => crate::i18n::fmt(tl!("line {line}"), &[("line", &line)]),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The script's lines an entry lists: `line 4`, `lines 4, 9, 12`.
|
||||
fn lines(item: &Value) -> String {
|
||||
let lines: Vec<String> = item["lines"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(|l| l.to_string()).collect();
|
||||
match lines.as_slice() {
|
||||
[] => String::new(),
|
||||
[line] => crate::i18n::fmt(tl!("line {line}"), &[("line", line)]),
|
||||
_ => crate::i18n::fmt(tl!("lines {lines}"), &[("lines", &lines.join(", "))]),
|
||||
}
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let Some(r) = d.fields.get("report") else { return true };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(r["name"].as_str().unwrap_or_default()).font(theme::semibold(12.5)).color(t.text_strong));
|
||||
ui.add_space(4.0);
|
||||
match r["kind"].as_str() {
|
||||
Some("script") => report(ui, r, &t),
|
||||
Some("compiled") => {
|
||||
widgets::dim_label(ui, tl!("Compiled script: ask its author for the .jsx file"));
|
||||
}
|
||||
Some("tooLarge") => {
|
||||
widgets::dim_label(ui, tl!("Too large to check"));
|
||||
}
|
||||
_ => {
|
||||
widgets::dim_label(ui, tl!("Not a script for the reference app"));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A row of a section: `text` (user data in `mono`), then a count and lines, dimmed.
|
||||
fn row(ui: &mut egui::Ui, t: &Tokens, text: &str, mono: bool, note: &str) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let text = egui::RichText::new(text).color(t.text_strong);
|
||||
ui.label(if mono { text.monospace() } else { text.size(12.5) });
|
||||
if !note.is_empty() {
|
||||
ui.label(egui::RichText::new(note).size(11.5).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `count` and `lines` of an entry as a note: `3 · lines 4, 9, 12`.
|
||||
fn tally(item: &Value) -> String {
|
||||
let count = item["count"].as_u64().unwrap_or_default();
|
||||
match lines(item) {
|
||||
l if l.is_empty() => count.to_string(),
|
||||
l => format!("{count} · {l}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, heading: &str, rows: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.add_space(8.0);
|
||||
widgets::section_header(ui, heading);
|
||||
rows(ui);
|
||||
}
|
||||
|
||||
fn list<'r>(r: &'r Value, key: &str) -> &'r [Value] {
|
||||
r[key].as_array().map_or(&[][..], Vec::as_slice)
|
||||
}
|
||||
|
||||
fn report(ui: &mut egui::Ui, r: &Value, t: &Tokens) {
|
||||
widgets::dim_label(ui, tl!("VectorCraft read this script without running it."));
|
||||
let needs: Vec<&str> = list(r, "needs").iter().filter_map(Value::as_str).filter_map(need_label).collect();
|
||||
if needs.is_empty() {
|
||||
row(ui, t, tl!("Needs nothing beyond plain JavaScript"), false, "");
|
||||
}
|
||||
for need in needs {
|
||||
row(ui, t, need, false, "");
|
||||
}
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let outside = list(r, "outside");
|
||||
if !outside.is_empty() {
|
||||
section(ui, tl!("Outside the document"), |ui| {
|
||||
for o in outside {
|
||||
let Some(label) = o["id"].as_str().and_then(outside_label) else { continue };
|
||||
let modes: Vec<&str> = list(o, "modes").iter().filter_map(Value::as_str).collect();
|
||||
let label = if modes.is_empty() { label.to_string() } else { format!("{label} ({})", modes.join(", ")) };
|
||||
row(ui, t, &label, false, &tally(o));
|
||||
}
|
||||
});
|
||||
}
|
||||
let menu = &r["menuCommands"];
|
||||
let dynamic = menu["dynamic"].as_u64().unwrap_or_default();
|
||||
if !list(menu, "strings").is_empty() || dynamic > 0 {
|
||||
section(ui, tl!("Menu commands"), |ui| {
|
||||
for s in list(menu, "strings") {
|
||||
row(ui, t, s["value"].as_str().unwrap_or_default(), true, &tally(s));
|
||||
}
|
||||
if dynamic > 0 {
|
||||
let count = dynamic.to_string();
|
||||
row(ui, t, &crate::i18n::fmt(tl!("Names the script builds as it runs: {count}"), &[("count", &count)]), false, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
let dom = &r["dom"];
|
||||
let types: Vec<&str> = list(dom, "types").iter().filter_map(|n| n["name"].as_str()).collect();
|
||||
if !list(dom, "members").is_empty() || !types.is_empty() {
|
||||
section(ui, tl!("Document model"), |ui| {
|
||||
for m in list(dom, "members") {
|
||||
row(ui, t, m["path"].as_str().unwrap_or_default(), true, &m["count"].as_u64().unwrap_or_default().to_string());
|
||||
}
|
||||
if !types.is_empty() {
|
||||
row(ui, t, &crate::i18n::fmt(tl!("Object types: {names}"), &[("names", &types.join(", "))]), false, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
let directives = list(r, "directives");
|
||||
if !directives.is_empty() {
|
||||
section(ui, tl!("Directives"), |ui| {
|
||||
for d in directives {
|
||||
let text = format!(
|
||||
"{}{} {}",
|
||||
d["form"].as_str().unwrap_or_default(),
|
||||
d["name"].as_str().unwrap_or_default(),
|
||||
d["value"].as_str().unwrap_or_default()
|
||||
);
|
||||
row(ui, t, text.trim_end(), true, &place(d));
|
||||
}
|
||||
});
|
||||
}
|
||||
let includes = list(r, "includes");
|
||||
if !includes.is_empty() {
|
||||
section(ui, tl!("Includes"), |ui| {
|
||||
for i in includes {
|
||||
let status = i["status"].as_str().and_then(status_label).unwrap_or_default();
|
||||
let note = [status.to_string(), place(i)].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
|
||||
row(ui, t, i["value"].as_str().unwrap_or_default(), true, ¬e);
|
||||
}
|
||||
});
|
||||
}
|
||||
let problems = list(r, "problems");
|
||||
if !problems.is_empty() {
|
||||
section(ui, tl!("Problems"), |ui| {
|
||||
for p in problems {
|
||||
let Some(label) = p["code"].as_str().and_then(problem_label) else { continue };
|
||||
let label = match p["detail"].as_str() {
|
||||
Some(detail) => format!("{label}: {detail}"),
|
||||
None => label.to_string(),
|
||||
};
|
||||
row(ui, t, &label, false, &place(p));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
});
|
||||
if r["truncated"].as_bool() == Some(true) {
|
||||
ui.add_space(6.0);
|
||||
widgets::dim_label(ui, tl!("Some lists were cut short at their limit."));
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@
|
||||
Export Selection… Exportovat výběr…
|
||||
Package… Sbalit…
|
||||
Scripts Skripty
|
||||
Check Script… Zkontrolovat skript…
|
||||
Check Script Zkontrolovat skript
|
||||
Other Script… Jiný skript…
|
||||
Document Setup… Nastavení dokumentu…
|
||||
Document Color Mode Barevný režim dokumentu
|
||||
@@ -2476,6 +2478,56 @@ lighting Light Světlo
|
||||
Selected Swatches Only Pouze vybrané položky vzorníku
|
||||
Selected Swatches Only (0) Pouze vybrané položky vzorníku (0)
|
||||
User Defined Libraries Uživatelské knihovny
|
||||
Copy Report Kopírovat zprávu
|
||||
VectorCraft read this script without running it. VectorCraft tento skript přečetl, aniž by ho spustil.
|
||||
Needs nothing beyond plain JavaScript Nepotřebuje nic kromě běžného JavaScriptu
|
||||
Needs the script language's own objects or directives Potřebuje vlastní objekty nebo direktivy skriptovacího jazyka
|
||||
Needs the document model Potřebuje model dokumentu
|
||||
Needs files Potřebuje soubory
|
||||
Needs menu commands Potřebuje příkazy nabídky
|
||||
Needs dialogs Potřebuje dialogová okna
|
||||
Needs features only the reference app has Potřebuje funkce, které má jen referenční aplikace
|
||||
Outside the document Mimo dokument
|
||||
Writes, moves or deletes files Zapisuje, přesouvá nebo maže soubory
|
||||
Opens files in other apps Otevírá soubory v jiných aplikacích
|
||||
Opens network connections Otevírá síťová připojení
|
||||
Loads native code Načítá nativní kód
|
||||
Sends messages to other apps Posílá zprávy jiným aplikacím
|
||||
Reads or sets environment variables Čte nebo nastavuje proměnné prostředí
|
||||
Runs code the checker can't read Spouští kód, který kontrola nedokáže přečíst
|
||||
Changes the reference app's preferences Mění předvolby referenční aplikace
|
||||
Menu commands Příkazy nabídky
|
||||
Names the script builds as it runs: {count} Názvy, které skript sestaví až za běhu: {count}
|
||||
Document model Model dokumentu
|
||||
Object types: {names} Typy objektů: {names}
|
||||
Directives Direktivy
|
||||
Includes Vložené soubory
|
||||
Checked Zkontrolováno
|
||||
Not followed: outside the script's folder Přeskočeno: mimo složku skriptu
|
||||
Includes itself Vkládá sám sebe
|
||||
Not read Nenačteno
|
||||
Not read: a limit was reached Nenačteno: byl dosažen limit
|
||||
Compiled or not text Kompilovaný nebo netextový soubor
|
||||
Problems Problémy
|
||||
String not closed Neuzavřený řetězec
|
||||
Comment not closed Neuzavřený komentář
|
||||
Regular expression not closed Neuzavřený regulární výraz
|
||||
Template literal not closed Neuzavřený šablonový literál
|
||||
XML literal not closed Neuzavřený literál XML
|
||||
Bracket never closed Neuzavřená závorka
|
||||
Closing bracket with no opening one Uzavírací závorka bez otevírací
|
||||
Syntax newer than ES3 Syntaxe novější než ES3
|
||||
Unknown directive Neznámá direktiva
|
||||
Not valid UTF-8: unreadable bytes were replaced Neplatné UTF-8: nečitelné bajty byly nahrazeny
|
||||
Mixed line endings Smíšené konce řádků
|
||||
Nesting too deep to follow Vnoření je příliš hluboké na sledování
|
||||
line {line} řádek {line}
|
||||
lines {lines} řádky {lines}
|
||||
{file}, line {line} {file}, řádek {line}
|
||||
Compiled script: ask its author for the .jsx file Kompilovaný skript: požádejte jeho autora o soubor .jsx
|
||||
Too large to check Příliš velký na kontrolu
|
||||
Not a script for the reference app Nejde o skript pro referenční aplikaci
|
||||
Some lists were cut short at their limit. Některé seznamy byly zkráceny na svůj limit.
|
||||
Line Segment Tool Options Možnosti nástroje Úsečka
|
||||
Alt: Alt:
|
||||
Divide Horizontally Into Rozdělit vodorovně na
|
||||
@@ -3705,6 +3757,7 @@ imageTrace Outline Obrys
|
||||
@msg Convert to Artboards: selection has no area Převést na pracovní plochy: výběr nemá žádnou plochu
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Převést na tvar: nejsou vybrány žádné obdélníky ani elipsy
|
||||
@msg Copied the CSS of {rules} object(s) Zkopírováno CSS objektů: {rules}
|
||||
@msg Copied the script report Zpráva o skriptu zkopírována
|
||||
@msg Corners edits the corners of a path Rohy upravuje rohy cesty
|
||||
@msg Couldn't copy to the system clipboard: {e} Nelze kopírovat do systémové schránky: {e}
|
||||
@msg Couldn't open {name}: {e} {name} nelze otevřít: {e}
|
||||
@@ -4195,6 +4248,7 @@ imageTrace Outline Obrys
|
||||
@msg {name}: no such recovery entry {name}: taková položka obnovení neexistuje
|
||||
@msg {path} can't be found: relink it first {path} nelze najít: nejprve jej znovu propojte
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: zde není souborový systém (k otevření předejte dataBase64; vynecháním cesty dataBase64 získáte)
|
||||
@msg {path}: not a file {path}: nejde o soubor
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} {name} nelze umístit: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -351,6 +351,59 @@
|
||||
Character Style Options Zeichenformatoptionen
|
||||
Character Styles Zeichenformate
|
||||
Check Links Verknüpfungen prüfen
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Skript prüfen
|
||||
Check Script… Skript prüfen…
|
||||
Copy Report Bericht kopieren
|
||||
VectorCraft read this script without running it. VectorCraft hat dieses Skript gelesen, ohne es auszuführen.
|
||||
Needs nothing beyond plain JavaScript Braucht nur einfaches JavaScript
|
||||
Needs the script language's own objects or directives Braucht eigene Objekte oder Direktiven der Skriptsprache
|
||||
Needs the document model Braucht das Dokumentmodell
|
||||
Needs files Braucht Dateien
|
||||
Needs menu commands Braucht Menübefehle
|
||||
Needs dialogs Braucht Dialogfelder
|
||||
Needs features only the reference app has Braucht Funktionen, die nur die Referenzanwendung hat
|
||||
Outside the document Außerhalb des Dokuments
|
||||
Writes, moves or deletes files Schreibt, verschiebt oder löscht Dateien
|
||||
Opens files in other apps Öffnet Dateien in anderen Apps
|
||||
Opens network connections Öffnet Netzwerkverbindungen
|
||||
Loads native code Lädt nativen Code
|
||||
Sends messages to other apps Sendet Nachrichten an andere Apps
|
||||
Reads or sets environment variables Liest oder setzt Umgebungsvariablen
|
||||
Runs code the checker can't read Führt Code aus, den die Prüfung nicht lesen kann
|
||||
Changes the reference app's preferences Ändert die Voreinstellungen der Referenzanwendung
|
||||
Menu commands Menübefehle
|
||||
Names the script builds as it runs: {count} Namen, die das Skript bei der Ausführung bildet: {count}
|
||||
Document model Dokumentmodell
|
||||
Object types: {names} Objekttypen: {names}
|
||||
Directives Direktiven
|
||||
Includes Eingebundene Dateien
|
||||
Checked Geprüft
|
||||
Not followed: outside the script's folder Übersprungen: außerhalb des Skriptordners
|
||||
Includes itself Bindet sich selbst ein
|
||||
Not read Nicht gelesen
|
||||
Not read: a limit was reached Nicht gelesen: ein Grenzwert wurde erreicht
|
||||
Compiled or not text Kompiliert oder kein Text
|
||||
Problems Probleme
|
||||
String not closed Zeichenkette nicht geschlossen
|
||||
Comment not closed Kommentar nicht geschlossen
|
||||
Regular expression not closed Regulärer Ausdruck nicht geschlossen
|
||||
Template literal not closed Template-Literal nicht geschlossen
|
||||
XML literal not closed XML-Literal nicht geschlossen
|
||||
Bracket never closed Klammer nie geschlossen
|
||||
Closing bracket with no opening one Schließende Klammer ohne öffnende
|
||||
Syntax newer than ES3 Neuere Syntax als ES3
|
||||
Unknown directive Unbekannte Direktive
|
||||
Not valid UTF-8: unreadable bytes were replaced Kein gültiges UTF-8: unlesbare Bytes wurden ersetzt
|
||||
Mixed line endings Gemischte Zeilenenden
|
||||
Nesting too deep to follow Verschachtelung zu tief zum Verfolgen
|
||||
line {line} Zeile {line}
|
||||
lines {lines} Zeilen {lines}
|
||||
{file}, line {line} {file}, Zeile {line}
|
||||
Compiled script: ask its author for the .jsx file Kompiliertes Skript: Bitten Sie den Autor um die .jsx-Datei
|
||||
Too large to check Zu groß zum Prüfen
|
||||
Not a script for the reference app Kein Skript für die Referenzanwendung
|
||||
Some lists were cut short at their limit. Einige Listen wurden an ihrem Grenzwert abgeschnitten.
|
||||
Check Spelling… Rechtschreibprüfung…
|
||||
Choose a folder Ordner wählen
|
||||
Choose… Wählen…
|
||||
@@ -3306,6 +3359,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg Convert to Artboards: selection has no area In Zeichenflächen umwandeln: Die Auswahl hat keine Fläche
|
||||
@msg Convert to Shape: no rectangles or ellipses selected In Form umwandeln: keine Rechtecke oder Ellipsen ausgewählt
|
||||
@msg Copied the CSS of {rules} object(s) CSS von {rules} Objekt(en) kopiert
|
||||
@msg Copied the script report Skriptbericht kopiert
|
||||
@msg Corners edits the corners of a path „Ecken“ bearbeitet die Ecken eines Pfads
|
||||
@msg Couldn't copy to the system clipboard: {e} In die Systemzwischenablage konnte nicht kopiert werden: {e}
|
||||
@msg Couldn't open {name}: {e} {name} konnte nicht geöffnet werden: {e}
|
||||
@@ -3797,6 +3851,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg {name}: no such recovery entry {name}: kein solcher Wiederherstellungseintrag
|
||||
@msg {path} can't be found: relink it first {path} wurde nicht gefunden: Verknüpfen Sie die Datei zuerst neu
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: Hier gibt es kein Dateisystem (übergeben Sie dataBase64 zum Öffnen; lassen Sie den Pfad weg, um dataBase64 zu erhalten)
|
||||
@msg {path}: not a file {path}: keine Datei
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} {name} konnte nicht platziert werden: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -369,6 +369,59 @@
|
||||
Character Style Options Opciones de estilo de carácter
|
||||
Character Styles Estilos de carácter
|
||||
Check Links Comprobar vínculos
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Comprobar script
|
||||
Check Script… Comprobar script…
|
||||
Copy Report Copiar informe
|
||||
VectorCraft read this script without running it. VectorCraft leyó este script sin ejecutarlo.
|
||||
Needs nothing beyond plain JavaScript Solo necesita JavaScript simple
|
||||
Needs the script language's own objects or directives Necesita objetos o directivas propios del lenguaje del script
|
||||
Needs the document model Necesita el modelo de documento
|
||||
Needs files Necesita archivos
|
||||
Needs menu commands Necesita comandos de menú
|
||||
Needs dialogs Necesita cuadros de diálogo
|
||||
Needs features only the reference app has Necesita funciones que solo tiene la aplicación de referencia
|
||||
Outside the document Fuera del documento
|
||||
Writes, moves or deletes files Escribe, mueve o elimina archivos
|
||||
Opens files in other apps Abre archivos en otras aplicaciones
|
||||
Opens network connections Abre conexiones de red
|
||||
Loads native code Carga código nativo
|
||||
Sends messages to other apps Envía mensajes a otras aplicaciones
|
||||
Reads or sets environment variables Lee o define variables de entorno
|
||||
Runs code the checker can't read Ejecuta código que la comprobación no puede leer
|
||||
Changes the reference app's preferences Cambia las preferencias de la aplicación de referencia
|
||||
Menu commands Comandos de menú
|
||||
Names the script builds as it runs: {count} Nombres que el script arma al ejecutarse: {count}
|
||||
Document model Modelo de documento
|
||||
Object types: {names} Tipos de objeto: {names}
|
||||
Directives Directivas
|
||||
Includes Archivos incluidos
|
||||
Checked Comprobado
|
||||
Not followed: outside the script's folder Omitido: fuera de la carpeta del script
|
||||
Includes itself Se incluye a sí mismo
|
||||
Not read No leído
|
||||
Not read: a limit was reached No leído: se alcanzó un límite
|
||||
Compiled or not text Compilado o no es texto
|
||||
Problems Problemas
|
||||
String not closed Cadena sin cerrar
|
||||
Comment not closed Comentario sin cerrar
|
||||
Regular expression not closed Expresión regular sin cerrar
|
||||
Template literal not closed Literal de plantilla sin cerrar
|
||||
XML literal not closed Literal XML sin cerrar
|
||||
Bracket never closed Delimitador sin cerrar
|
||||
Closing bracket with no opening one Delimitador de cierre sin apertura
|
||||
Syntax newer than ES3 Sintaxis más reciente que ES3
|
||||
Unknown directive Directiva desconocida
|
||||
Not valid UTF-8: unreadable bytes were replaced UTF-8 no válido: se reemplazaron los bytes ilegibles
|
||||
Mixed line endings Finales de línea mezclados
|
||||
Nesting too deep to follow Anidamiento demasiado profundo para seguirlo
|
||||
line {line} línea {line}
|
||||
lines {lines} líneas {lines}
|
||||
{file}, line {line} {file}, línea {line}
|
||||
Compiled script: ask its author for the .jsx file Script compilado: pídele el archivo .jsx a su autor
|
||||
Too large to check Demasiado grande para comprobarlo
|
||||
Not a script for the reference app No es un script para la aplicación de referencia
|
||||
Some lists were cut short at their limit. Algunas listas se recortaron al llegar a su límite.
|
||||
Check Spelling… Revisar ortografía…
|
||||
Choose a folder Elige una carpeta
|
||||
Choose… Elegir…
|
||||
@@ -3406,6 +3459,7 @@ axis Both Ambos
|
||||
@msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados
|
||||
@msg Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
|
||||
@msg Copied the script report Se copió el informe del script
|
||||
@msg Corners edits the corners of a path Esquinas edita las esquinas de un trazado
|
||||
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
|
||||
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
|
||||
@@ -3897,6 +3951,7 @@ axis Both Ambos
|
||||
@msg {name}: no such recovery entry {name}: no existe esa entrada de recuperación
|
||||
@msg {path} can't be found: relink it first no se encuentra {path}: vuelve a vincularlo primero
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: aquí no hay sistema de archivos (pasa dataBase64 para abrir; omite la ruta para obtener dataBase64)
|
||||
@msg {path}: not a file {path}: no es un archivo
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} No se pudo colocar {name}: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -370,6 +370,59 @@
|
||||
Character Style Options Options de style de caractère
|
||||
Character Styles Styles de caractère
|
||||
Check Links Vérifier les liens
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Vérifier le script
|
||||
Check Script… Vérifier le script…
|
||||
Copy Report Copier le rapport
|
||||
VectorCraft read this script without running it. VectorCraft a lu ce script sans l’exécuter.
|
||||
Needs nothing beyond plain JavaScript Ne demande que du JavaScript simple
|
||||
Needs the script language's own objects or directives Demande les objets ou directives propres au langage du script
|
||||
Needs the document model Demande le modèle de document
|
||||
Needs files Demande des fichiers
|
||||
Needs menu commands Demande des commandes de menu
|
||||
Needs dialogs Demande des boîtes de dialogue
|
||||
Needs features only the reference app has Demande des fonctions que seule l’application de référence possède
|
||||
Outside the document Hors du document
|
||||
Writes, moves or deletes files Écrit, déplace ou supprime des fichiers
|
||||
Opens files in other apps Ouvre des fichiers dans d’autres applications
|
||||
Opens network connections Ouvre des connexions réseau
|
||||
Loads native code Charge du code natif
|
||||
Sends messages to other apps Envoie des messages à d’autres applications
|
||||
Reads or sets environment variables Lit ou définit des variables d’environnement
|
||||
Runs code the checker can't read Exécute du code que la vérification ne peut pas lire
|
||||
Changes the reference app's preferences Modifie les préférences de l’application de référence
|
||||
Menu commands Commandes de menu
|
||||
Names the script builds as it runs: {count} Noms construits par le script à l’exécution ({count})
|
||||
Document model Modèle de document
|
||||
Object types: {names} Types d’objets ({names})
|
||||
Directives Directives
|
||||
Includes Fichiers inclus
|
||||
Checked Vérifié
|
||||
Not followed: outside the script's folder Ignoré (hors du dossier du script)
|
||||
Includes itself S’inclut lui-même
|
||||
Not read Non lu
|
||||
Not read: a limit was reached Non lu (limite atteinte)
|
||||
Compiled or not text Compilé ou pas du texte
|
||||
Problems Problèmes
|
||||
String not closed Chaîne non fermée
|
||||
Comment not closed Commentaire non fermé
|
||||
Regular expression not closed Expression régulière non fermée
|
||||
Template literal not closed Littéral de gabarit non fermé
|
||||
XML literal not closed Littéral XML non fermé
|
||||
Bracket never closed Délimiteur jamais fermé
|
||||
Closing bracket with no opening one Délimiteur fermant sans ouvrant
|
||||
Syntax newer than ES3 Syntaxe plus récente qu’ES3
|
||||
Unknown directive Directive inconnue
|
||||
Not valid UTF-8: unreadable bytes were replaced UTF-8 non valide (octets illisibles remplacés)
|
||||
Mixed line endings Fins de ligne mélangées
|
||||
Nesting too deep to follow Imbrication trop profonde pour être suivie
|
||||
line {line} ligne {line}
|
||||
lines {lines} lignes {lines}
|
||||
{file}, line {line} {file}, ligne {line}
|
||||
Compiled script: ask its author for the .jsx file Script compilé. Demandez le fichier .jsx à son auteur
|
||||
Too large to check Trop volumineux pour être vérifié
|
||||
Not a script for the reference app Ce n’est pas un script pour l’application de référence
|
||||
Some lists were cut short at their limit. Certaines listes ont été tronquées à leur limite.
|
||||
Check Spelling… Vérifier l’orthographe…
|
||||
Choose a folder Choisissez un dossier
|
||||
Choose… Choisir…
|
||||
@@ -3403,6 +3456,7 @@ axis Both Les deux
|
||||
@msg Convert to Artboards: selection has no area Convertir en plans de travail : la sélection n’a pas de surface
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forme : aucun rectangle ni aucune ellipse sélectionné
|
||||
@msg Copied the CSS of {rules} object(s) CSS de {rules} objet(s) copié
|
||||
@msg Copied the script report Rapport du script copié
|
||||
@msg Corners edits the corners of a path Angles modifie les angles d’un tracé
|
||||
@msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e}
|
||||
@msg Couldn't open {name}: {e} Impossible d’ouvrir {name} : {e}
|
||||
@@ -3894,6 +3948,7 @@ axis Both Les deux
|
||||
@msg {name}: no such recovery entry {name} : entrée de récupération inexistante
|
||||
@msg {path} can't be found: relink it first {path} est introuvable : rétablissez d’abord le lien
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path} : aucun système de fichiers ici (transmettez dataBase64 pour ouvrir ; omettez le chemin pour obtenir dataBase64)
|
||||
@msg {path}: not a file {path} n’est pas un fichier
|
||||
@msg {path}: {e} {path} : {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} Impossible d’importer {name} : {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -531,6 +531,59 @@
|
||||
Character Styles Karakterstílusok
|
||||
Character: Karakter:
|
||||
Check Links Csatolások ellenőrzése
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Szkript ellenőrzése
|
||||
Check Script… Szkript ellenőrzése…
|
||||
Copy Report Jelentés másolása
|
||||
VectorCraft read this script without running it. A VectorCraft futtatás nélkül olvasta be ezt a szkriptet.
|
||||
Needs nothing beyond plain JavaScript Csak egyszerű JavaScript kell hozzá
|
||||
Needs the script language's own objects or directives A szkriptnyelv saját objektumai vagy direktívái kellenek hozzá
|
||||
Needs the document model A dokumentummodell kell hozzá
|
||||
Needs files Fájlok kellenek hozzá
|
||||
Needs menu commands Menüparancsok kellenek hozzá
|
||||
Needs dialogs Párbeszédablakok kellenek hozzá
|
||||
Needs features only the reference app has Olyan funkciók kellenek hozzá, amelyek csak a referenciaalkalmazásban vannak
|
||||
Outside the document A dokumentumon kívül
|
||||
Writes, moves or deletes files Fájlokat ír, áthelyez vagy töröl
|
||||
Opens files in other apps Fájlokat nyit meg más alkalmazásokban
|
||||
Opens network connections Hálózati kapcsolatokat nyit
|
||||
Loads native code Natív kódot tölt be
|
||||
Sends messages to other apps Üzeneteket küld más alkalmazásoknak
|
||||
Reads or sets environment variables Környezeti változókat olvas vagy állít be
|
||||
Runs code the checker can't read Olyan kódot futtat, amelyet az ellenőrzés nem tud beolvasni
|
||||
Changes the reference app's preferences Módosítja a referenciaalkalmazás beállításait
|
||||
Menu commands Menüparancsok
|
||||
Names the script builds as it runs: {count} A szkript által futás közben előállított nevek: {count}
|
||||
Document model Dokumentummodell
|
||||
Object types: {names} Objektumtípusok: {names}
|
||||
Directives Direktívák
|
||||
Includes Beillesztett fájlok
|
||||
Checked Ellenőrizve
|
||||
Not followed: outside the script's folder Kihagyva: a szkript mappáján kívül esik
|
||||
Includes itself Önmagát illeszti be
|
||||
Not read Nincs beolvasva
|
||||
Not read: a limit was reached Nincs beolvasva: korlát elérve
|
||||
Compiled or not text Lefordított vagy nem szöveg
|
||||
Problems Problémák
|
||||
String not closed Lezáratlan karakterlánc
|
||||
Comment not closed Lezáratlan megjegyzés
|
||||
Regular expression not closed Lezáratlan reguláris kifejezés
|
||||
Template literal not closed Lezáratlan sablonliterál
|
||||
XML literal not closed Lezáratlan XML-literál
|
||||
Bracket never closed Nyitva maradt zárójel
|
||||
Closing bracket with no opening one Záró zárójel nyitó pár nélkül
|
||||
Syntax newer than ES3 Az ES3-nál újabb szintaxis
|
||||
Unknown directive Ismeretlen direktíva
|
||||
Not valid UTF-8: unreadable bytes were replaced Érvénytelen UTF-8: az olvashatatlan bájtok lecserélve
|
||||
Mixed line endings Vegyes sorvégek
|
||||
Nesting too deep to follow Túl mély egymásba ágyazás
|
||||
line {line} sor: {line}
|
||||
lines {lines} sorok: {lines}
|
||||
{file}, line {line} {file}, sor: {line}
|
||||
Compiled script: ask its author for the .jsx file Lefordított szkript: kérd el a szerzőjétől a .jsx fájlt
|
||||
Too large to check Túl nagy az ellenőrzéshez
|
||||
Not a script for the reference app Nem a referenciaalkalmazáshoz írt szkript
|
||||
Some lists were cut short at their limit. Egyes listák a korlátjuknál megszakadtak.
|
||||
Check Spelling… Helyesírás-ellenőrzés…
|
||||
Chevron Csúcsos nyíl
|
||||
Choose a folder Válassz mappát
|
||||
@@ -4093,6 +4146,7 @@ imageTrace Outline Körvonal
|
||||
@msg Convert to Artboards: selection has no area Átalakítás rajztáblákká: a kijelölésnek nincs területe
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Átalakítás alakzattá: nincs kijelölt téglalap vagy ellipszis
|
||||
@msg Copied the CSS of {rules} object(s) CSS másolva, objektumok: {rules}
|
||||
@msg Copied the script report Szkriptjelentés másolva
|
||||
@msg Corners edits the corners of a path a Sarkok parancs egy útvonal sarkait szerkeszti
|
||||
@msg could not decode image: {0} a kép nem dekódolható: {0}
|
||||
@msg Couldn't copy to the system clipboard: {e} Nem sikerült a rendszer vágólapjára másolni: {e}
|
||||
@@ -4598,6 +4652,7 @@ imageTrace Outline Körvonal
|
||||
@msg {path} can't be found: relink it first nem található: {path}; előbb csatold újra
|
||||
@msg {path} is not a folder nem mappa: {path}
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: itt nincs fájlrendszer (megnyitáshoz add meg a dataBase64-et; az útvonal elhagyásával dataBase64-et kapsz)
|
||||
@msg {path}: not a file {path}: nem fájl
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg {tool} isn't in a toolbar group of several tools az eszköz nincs többeszközös eszköztár-csoportban: {tool}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -369,6 +369,59 @@
|
||||
Character Style Options Opzioni stile carattere
|
||||
Character Styles Stili carattere
|
||||
Check Links Verifica collegamenti
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Controlla script
|
||||
Check Script… Controlla script…
|
||||
Copy Report Copia rapporto
|
||||
VectorCraft read this script without running it. VectorCraft ha letto questo script senza eseguirlo.
|
||||
Needs nothing beyond plain JavaScript Richiede solo JavaScript semplice
|
||||
Needs the script language's own objects or directives Richiede oggetti o direttive propri del linguaggio dello script
|
||||
Needs the document model Richiede il modello del documento
|
||||
Needs files Richiede file
|
||||
Needs menu commands Richiede comandi di menu
|
||||
Needs dialogs Richiede finestre di dialogo
|
||||
Needs features only the reference app has Richiede funzioni che ha solo l'applicazione di riferimento
|
||||
Outside the document Fuori dal documento
|
||||
Writes, moves or deletes files Scrive, sposta o elimina file
|
||||
Opens files in other apps Apre file in altre app
|
||||
Opens network connections Apre connessioni di rete
|
||||
Loads native code Carica codice nativo
|
||||
Sends messages to other apps Invia messaggi ad altre app
|
||||
Reads or sets environment variables Legge o imposta variabili d'ambiente
|
||||
Runs code the checker can't read Esegue codice che il controllo non può leggere
|
||||
Changes the reference app's preferences Modifica le preferenze dell'applicazione di riferimento
|
||||
Menu commands Comandi di menu
|
||||
Names the script builds as it runs: {count} Nomi che lo script compone durante l'esecuzione: {count}
|
||||
Document model Modello del documento
|
||||
Object types: {names} Tipi di oggetto: {names}
|
||||
Directives Direttive
|
||||
Includes File inclusi
|
||||
Checked Controllato
|
||||
Not followed: outside the script's folder Ignorato: fuori dalla cartella dello script
|
||||
Includes itself Include se stesso
|
||||
Not read Non letto
|
||||
Not read: a limit was reached Non letto: è stato raggiunto un limite
|
||||
Compiled or not text Compilato o non testo
|
||||
Problems Problemi
|
||||
String not closed Stringa non chiusa
|
||||
Comment not closed Commento non chiuso
|
||||
Regular expression not closed Espressione regolare non chiusa
|
||||
Template literal not closed Letterale modello non chiuso
|
||||
XML literal not closed Letterale XML non chiuso
|
||||
Bracket never closed Parentesi mai chiusa
|
||||
Closing bracket with no opening one Parentesi di chiusura senza apertura
|
||||
Syntax newer than ES3 Sintassi più recente di ES3
|
||||
Unknown directive Direttiva sconosciuta
|
||||
Not valid UTF-8: unreadable bytes were replaced UTF-8 non valido: i byte illeggibili sono stati sostituiti
|
||||
Mixed line endings Fine riga miste
|
||||
Nesting too deep to follow Annidamento troppo profondo da seguire
|
||||
line {line} riga {line}
|
||||
lines {lines} righe {lines}
|
||||
{file}, line {line} {file}, riga {line}
|
||||
Compiled script: ask its author for the .jsx file Script compilato: chiedi il file .jsx al suo autore
|
||||
Too large to check Troppo grande da controllare
|
||||
Not a script for the reference app Non è uno script per l'applicazione di riferimento
|
||||
Some lists were cut short at their limit. Alcuni elenchi sono stati troncati al loro limite.
|
||||
Check Spelling… Controlla ortografia…
|
||||
Choose a folder Scegli una cartella
|
||||
Choose… Scegli…
|
||||
@@ -3406,6 +3459,7 @@ axis Both Entrambi
|
||||
@msg Convert to Artboards: selection has no area Converti in tavole da disegno: la selezione non ha area
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Converti in forma: nessun rettangolo o ellisse selezionato
|
||||
@msg Copied the CSS of {rules} object(s) Copiato il CSS di {rules} oggetto/i
|
||||
@msg Copied the script report Rapporto dello script copiato
|
||||
@msg Corners edits the corners of a path Angoli modifica gli angoli di un tracciato
|
||||
@msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e}
|
||||
@msg Couldn't open {name}: {e} Impossibile aprire {name}: {e}
|
||||
@@ -3897,6 +3951,7 @@ axis Both Entrambi
|
||||
@msg {name}: no such recovery entry {name}: voce di recupero inesistente
|
||||
@msg {path} can't be found: relink it first impossibile trovare {path}: ricollegalo prima
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: nessun file system disponibile qui (passa dataBase64 per aprire; ometti il percorso per ottenere dataBase64)
|
||||
@msg {path}: not a file {path}: non è un file
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} Impossibile inserire {name}: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -937,6 +937,59 @@
|
||||
Character Set: 文字セット:
|
||||
Character Style Options 文字スタイルオプション
|
||||
Check Links リンクを確認
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script スクリプトをチェック
|
||||
Check Script… スクリプトをチェック…
|
||||
Copy Report レポートをコピー
|
||||
VectorCraft read this script without running it. VectorCraft はこのスクリプトを実行せずに読み取りました。
|
||||
Needs nothing beyond plain JavaScript 通常の JavaScript 以外は不要
|
||||
Needs the script language's own objects or directives スクリプト言語固有のオブジェクトまたはディレクティブが必要
|
||||
Needs the document model ドキュメントモデルが必要
|
||||
Needs files ファイルが必要
|
||||
Needs menu commands メニューコマンドが必要
|
||||
Needs dialogs ダイアログが必要
|
||||
Needs features only the reference app has 参照アプリだけにある機能が必要
|
||||
Outside the document ドキュメントの外
|
||||
Writes, moves or deletes files ファイルを書き込み、移動、削除
|
||||
Opens files in other apps ほかのアプリでファイルを開く
|
||||
Opens network connections ネットワーク接続を開く
|
||||
Loads native code ネイティブコードを読み込む
|
||||
Sends messages to other apps ほかのアプリにメッセージを送る
|
||||
Reads or sets environment variables 環境変数を読み取りまたは設定
|
||||
Runs code the checker can't read チェックで読み取れないコードを実行
|
||||
Changes the reference app's preferences 参照アプリの環境設定を変更
|
||||
Menu commands メニューコマンド
|
||||
Names the script builds as it runs: {count} スクリプトが実行時に組み立てる名前:{count}
|
||||
Document model ドキュメントモデル
|
||||
Object types: {names} オブジェクトの種類:{names}
|
||||
Directives ディレクティブ
|
||||
Includes インクルード
|
||||
Checked チェック済み
|
||||
Not followed: outside the script's folder 読み込み対象外:スクリプトのフォルダーの外
|
||||
Includes itself 自身をインクルードしています
|
||||
Not read 読み込んでいません
|
||||
Not read: a limit was reached 読み込んでいません:上限に達しました
|
||||
Compiled or not text コンパイル済みかテキストではありません
|
||||
Problems 問題
|
||||
String not closed 文字列が閉じていません
|
||||
Comment not closed コメントが閉じていません
|
||||
Regular expression not closed 正規表現が閉じていません
|
||||
Template literal not closed テンプレートリテラルが閉じていません
|
||||
XML literal not closed XML リテラルが閉じていません
|
||||
Bracket never closed 括弧が閉じていません
|
||||
Closing bracket with no opening one 対応する開き括弧のない閉じ括弧
|
||||
Syntax newer than ES3 ES3 より新しい構文
|
||||
Unknown directive 不明なディレクティブ
|
||||
Not valid UTF-8: unreadable bytes were replaced 有効な UTF-8 ではありません:読み取れないバイトを置き換えました
|
||||
Mixed line endings 改行コードが混在しています
|
||||
Nesting too deep to follow 入れ子が深すぎてたどれません
|
||||
line {line} {line} 行目
|
||||
lines {lines} {lines} 行目
|
||||
{file}, line {line} {file}、{line} 行目
|
||||
Compiled script: ask its author for the .jsx file コンパイル済みスクリプト:作成者に .jsx ファイルを依頼してください
|
||||
Too large to check 大きすぎてチェックできません
|
||||
Not a script for the reference app 参照アプリ用のスクリプトではありません
|
||||
Some lists were cut short at their limit. 一部の一覧は上限で打ち切られました。
|
||||
Choose a folder フォルダーを選択
|
||||
Choose… 選択…
|
||||
Circle 円
|
||||
@@ -3558,6 +3611,7 @@ theme Appearance 外観
|
||||
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
|
||||
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
|
||||
@msg no library `{id}` ライブラリ `{id}` が見つかりません
|
||||
@msg {path}: not a file {path}: ファイルではありません
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg can't write .{ext} images .{ext} 画像は書き出せません
|
||||
@msg Replace Spine: select a blend and a path ブレンド軸を置き換え: ブレンドとパスを選択してください
|
||||
@@ -3917,6 +3971,7 @@ theme Appearance 外観
|
||||
@msg {_1} font families available {_1} 個のフォントファミリーを使用できます
|
||||
@msg {_1} has no {_2} style {_1} には {_2} スタイルがありません
|
||||
@msg Copied the CSS of {rules} object(s) {rules} 個のオブジェクトの CSS をコピーしました
|
||||
@msg Copied the script report スクリプトのレポートをコピーしました
|
||||
@msg Exported {path} {path} を書き出しました
|
||||
@msg no CSS to copy: select objects with CSS of their own コピーする CSS がありません: 独自の CSS を持つオブジェクトを選択してください
|
||||
@msg unknown highlight `{h}` `{h}` は不明なハイライトの指定です
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -932,6 +932,59 @@
|
||||
Character Set: 문자 집합:
|
||||
Character Style Options 문자 스타일 옵션
|
||||
Check Links 링크 확인
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script 스크립트 확인
|
||||
Check Script… 스크립트 확인…
|
||||
Copy Report 보고서 복사
|
||||
VectorCraft read this script without running it. VectorCraft가 이 스크립트를 실행하지 않고 읽었습니다.
|
||||
Needs nothing beyond plain JavaScript 일반 JavaScript 외에는 필요 없음
|
||||
Needs the script language's own objects or directives 스크립트 언어 자체의 개체 또는 지시문 필요
|
||||
Needs the document model 문서 모델 필요
|
||||
Needs files 파일 필요
|
||||
Needs menu commands 메뉴 명령 필요
|
||||
Needs dialogs 대화상자 필요
|
||||
Needs features only the reference app has 참조 앱에만 있는 기능 필요
|
||||
Outside the document 문서 바깥
|
||||
Writes, moves or deletes files 파일 쓰기, 이동 또는 삭제
|
||||
Opens files in other apps 다른 앱에서 파일 열기
|
||||
Opens network connections 네트워크 연결 열기
|
||||
Loads native code 네이티브 코드 로드
|
||||
Sends messages to other apps 다른 앱에 메시지 보내기
|
||||
Reads or sets environment variables 환경 변수 읽기 또는 설정
|
||||
Runs code the checker can't read 검사기가 읽을 수 없는 코드 실행
|
||||
Changes the reference app's preferences 참조 앱의 환경 설정 변경
|
||||
Menu commands 메뉴 명령
|
||||
Names the script builds as it runs: {count} 스크립트가 실행 중에 만드는 이름: {count}
|
||||
Document model 문서 모델
|
||||
Object types: {names} 개체 유형: {names}
|
||||
Directives 지시문
|
||||
Includes 포함 파일
|
||||
Checked 확인됨
|
||||
Not followed: outside the script's folder 따라가지 않음: 스크립트 폴더 바깥
|
||||
Includes itself 자기 자신을 포함
|
||||
Not read 읽지 않음
|
||||
Not read: a limit was reached 읽지 않음: 한도에 도달함
|
||||
Compiled or not text 컴파일되었거나 텍스트가 아님
|
||||
Problems 문제
|
||||
String not closed 문자열이 닫히지 않음
|
||||
Comment not closed 주석이 닫히지 않음
|
||||
Regular expression not closed 정규식이 닫히지 않음
|
||||
Template literal not closed 템플릿 리터럴이 닫히지 않음
|
||||
XML literal not closed XML 리터럴이 닫히지 않음
|
||||
Bracket never closed 괄호가 닫히지 않음
|
||||
Closing bracket with no opening one 여는 괄호가 없는 닫는 괄호
|
||||
Syntax newer than ES3 ES3보다 새로운 구문
|
||||
Unknown directive 알 수 없는 지시문
|
||||
Not valid UTF-8: unreadable bytes were replaced 유효한 UTF-8이 아님: 읽을 수 없는 바이트를 바꿨습니다
|
||||
Mixed line endings 줄 끝 형식이 섞여 있음
|
||||
Nesting too deep to follow 중첩이 너무 깊어 따라갈 수 없음
|
||||
line {line} {line}행
|
||||
lines {lines} {lines}행
|
||||
{file}, line {line} {file}, {line}행
|
||||
Compiled script: ask its author for the .jsx file 컴파일된 스크립트: 작성자에게 .jsx 파일을 요청하세요
|
||||
Too large to check 너무 커서 확인할 수 없음
|
||||
Not a script for the reference app 참조 앱용 스크립트가 아님
|
||||
Some lists were cut short at their limit. 일부 목록은 한도에서 잘렸습니다.
|
||||
Choose a folder 폴더 선택
|
||||
Choose… 선택…
|
||||
Circle 원
|
||||
@@ -3536,6 +3589,7 @@ theme Appearance 외관
|
||||
@msg the layer to flatten into is hidden 병합 대상 레이어가 숨겨져 있습니다
|
||||
@msg there are no objects to release 해제할 개체가 없습니다
|
||||
@msg no library `{id}` `{id}` 라이브러리가 없습니다
|
||||
@msg {path}: not a file {path}: 파일이 아닙니다
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg can't write .{ext} images .{ext} 이미지는 쓸 수 없습니다
|
||||
@msg Replace Spine: select a blend and a path 스파인 교체: 블렌드와 패스를 선택하세요
|
||||
@@ -3895,6 +3949,7 @@ theme Appearance 외관
|
||||
@msg {_1} font families available 사용 가능한 글꼴 패밀리 {_1}개
|
||||
@msg {_1} has no {_2} style {_1}에는 {_2} 스타일이 없습니다
|
||||
@msg Copied the CSS of {rules} object(s) 개체 {rules}개의 CSS를 복사했습니다
|
||||
@msg Copied the script report 스크립트 보고서를 복사했습니다
|
||||
@msg Exported {path} {path}을(를) 내보냈습니다
|
||||
@msg no CSS to copy: select objects with CSS of their own 복사할 CSS가 없습니다: 자체 CSS가 있는 개체를 선택하세요
|
||||
@msg unknown highlight `{h}` 알 수 없는 강조 `{h}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -64,6 +64,8 @@
|
||||
Export Selection… Eksportuj zaznaczenie…
|
||||
Package… Spakuj…
|
||||
Scripts Skrypty
|
||||
Check Script… Sprawdź skrypt…
|
||||
Check Script Sprawdź skrypt
|
||||
Other Script… Inny skrypt…
|
||||
Document Setup… Ustawienia dokumentu…
|
||||
Document Color Mode Tryb koloru dokumentu
|
||||
@@ -2391,6 +2393,56 @@ lighting Light Światło
|
||||
Saved to User Defined: {} Zapisano w zdefiniowanych przez użytkownika: {}
|
||||
Selected Swatches Only Tylko zaznaczone próbki
|
||||
User Defined Libraries Biblioteki zdefiniowane przez użytkownika
|
||||
Copy Report Kopiuj raport
|
||||
VectorCraft read this script without running it. VectorCraft odczytał ten skrypt, nie uruchamiając go.
|
||||
Needs nothing beyond plain JavaScript Wymaga tylko zwykłego JavaScriptu
|
||||
Needs the script language's own objects or directives Wymaga własnych obiektów lub dyrektyw języka skryptowego
|
||||
Needs the document model Wymaga modelu dokumentu
|
||||
Needs files Wymaga plików
|
||||
Needs menu commands Wymaga poleceń menu
|
||||
Needs dialogs Wymaga okien dialogowych
|
||||
Needs features only the reference app has Wymaga funkcji, które ma tylko aplikacja referencyjna
|
||||
Outside the document Poza dokumentem
|
||||
Writes, moves or deletes files Zapisuje, przenosi lub usuwa pliki
|
||||
Opens files in other apps Otwiera pliki w innych aplikacjach
|
||||
Opens network connections Otwiera połączenia sieciowe
|
||||
Loads native code Wczytuje kod natywny
|
||||
Sends messages to other apps Wysyła komunikaty do innych aplikacji
|
||||
Reads or sets environment variables Odczytuje lub ustawia zmienne środowiskowe
|
||||
Runs code the checker can't read Uruchamia kod, którego nie da się odczytać podczas sprawdzania
|
||||
Changes the reference app's preferences Zmienia preferencje aplikacji referencyjnej
|
||||
Menu commands Polecenia menu
|
||||
Names the script builds as it runs: {count} Nazwy tworzone przez skrypt w trakcie działania: {count}
|
||||
Document model Model dokumentu
|
||||
Object types: {names} Typy obiektów: {names}
|
||||
Directives Dyrektywy
|
||||
Includes Dołączone pliki
|
||||
Checked Sprawdzono
|
||||
Not followed: outside the script's folder Pominięto: poza folderem skryptu
|
||||
Includes itself Dołącza samego siebie
|
||||
Not read Nie odczytano
|
||||
Not read: a limit was reached Nie odczytano: osiągnięto limit
|
||||
Compiled or not text Plik skompilowany lub nietekstowy
|
||||
Problems Problemy
|
||||
String not closed Niezamknięty ciąg znaków
|
||||
Comment not closed Niezamknięty komentarz
|
||||
Regular expression not closed Niezamknięte wyrażenie regularne
|
||||
Template literal not closed Niezamknięty literał szablonowy
|
||||
XML literal not closed Niezamknięty literał XML
|
||||
Bracket never closed Niezamknięty nawias
|
||||
Closing bracket with no opening one Nawias zamykający bez otwierającego
|
||||
Syntax newer than ES3 Składnia nowsza niż ES3
|
||||
Unknown directive Nieznana dyrektywa
|
||||
Not valid UTF-8: unreadable bytes were replaced Nieprawidłowe kodowanie UTF-8: nieczytelne bajty zostały zastąpione
|
||||
Mixed line endings Mieszane znaki końca wiersza
|
||||
Nesting too deep to follow Zagnieżdżenie zbyt głębokie, aby je śledzić
|
||||
line {line} wiersz {line}
|
||||
lines {lines} wiersze {lines}
|
||||
{file}, line {line} {file}, wiersz {line}
|
||||
Compiled script: ask its author for the .jsx file Skompilowany skrypt: poproś autora o plik .jsx
|
||||
Too large to check Zbyt duży, aby go sprawdzić
|
||||
Not a script for the reference app To nie jest skrypt dla aplikacji referencyjnej
|
||||
Some lists were cut short at their limit. Niektóre listy zostały obcięte po osiągnięciu limitu.
|
||||
Line Segment Tool Options Opcje narzędzia Odcinek
|
||||
Alt: Alt:
|
||||
Divide Horizontally Into Podziel poziomo na
|
||||
@@ -3571,6 +3623,7 @@ imageTrace Outline Kontur
|
||||
@msg Convert to Artboards: selection has no area Konwertuj na obszary kompozycji: zaznaczenie nie ma powierzchni
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Konwertuj na kształt: nie zaznaczono prostokątów ani elips
|
||||
@msg Copied the CSS of {rules} object(s) Skopiowano CSS obiektów: {rules}
|
||||
@msg Copied the script report Skopiowano raport skryptu
|
||||
@msg Corners edits the corners of a path Narożniki edytuje narożniki ścieżki
|
||||
@msg Couldn't copy to the system clipboard: {e} Nie udało się skopiować do schowka systemowego: {e}
|
||||
@msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
|
||||
@@ -4061,6 +4114,7 @@ imageTrace Outline Kontur
|
||||
@msg {name}: no such recovery entry {name}: brak takiego wpisu odzyskiwania
|
||||
@msg {path} can't be found: relink it first nie można znaleźć {path}: najpierw połącz go ponownie
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: brak systemu plików (przekaż dataBase64, aby otworzyć; pomiń ścieżkę, aby otrzymać dataBase64)
|
||||
@msg {path}: not a file {path}: nie jest plikiem
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} Nie udało się umieścić {name}: {e}
|
||||
@@ -4521,7 +4575,6 @@ mark-style Japanese Japońskie
|
||||
@msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt
|
||||
Add Guides Dodaj linie pomocnicze
|
||||
Total: Razem:
|
||||
# Strings added to the app after the catalog was written (translated by the maintainers; not reviewed by a native speaker).
|
||||
Even Równe
|
||||
Legends in Wedges Legendy w wycinkach
|
||||
No Legend Bez legendy
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -349,6 +349,59 @@
|
||||
Character Style Options Opções de Estilo de Caractere
|
||||
Character Styles Estilos de Caractere
|
||||
Check Links Verificar Links
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Verificar Script
|
||||
Check Script… Verificar Script…
|
||||
Copy Report Copiar Relatório
|
||||
VectorCraft read this script without running it. O VectorCraft leu este script sem executá-lo.
|
||||
Needs nothing beyond plain JavaScript Precisa apenas de JavaScript simples
|
||||
Needs the script language's own objects or directives Precisa de objetos ou diretivas próprios da linguagem do script
|
||||
Needs the document model Precisa do modelo de documento
|
||||
Needs files Precisa de arquivos
|
||||
Needs menu commands Precisa de comandos de menu
|
||||
Needs dialogs Precisa de caixas de diálogo
|
||||
Needs features only the reference app has Precisa de recursos que só o aplicativo de referência tem
|
||||
Outside the document Fora do documento
|
||||
Writes, moves or deletes files Grava, move ou exclui arquivos
|
||||
Opens files in other apps Abre arquivos em outros aplicativos
|
||||
Opens network connections Abre conexões de rede
|
||||
Loads native code Carrega código nativo
|
||||
Sends messages to other apps Envia mensagens a outros aplicativos
|
||||
Reads or sets environment variables Lê ou define variáveis de ambiente
|
||||
Runs code the checker can't read Executa código que a verificação não consegue ler
|
||||
Changes the reference app's preferences Altera as preferências do aplicativo de referência
|
||||
Menu commands Comandos de menu
|
||||
Names the script builds as it runs: {count} Nomes que o script monta durante a execução: {count}
|
||||
Document model Modelo de documento
|
||||
Object types: {names} Tipos de objeto: {names}
|
||||
Directives Diretivas
|
||||
Includes Arquivos incluídos
|
||||
Checked Verificado
|
||||
Not followed: outside the script's folder Ignorado: fora da pasta do script
|
||||
Includes itself Inclui a si mesmo
|
||||
Not read Não lido
|
||||
Not read: a limit was reached Não lido: um limite foi atingido
|
||||
Compiled or not text Compilado ou não é texto
|
||||
Problems Problemas
|
||||
String not closed String não fechada
|
||||
Comment not closed Comentário não fechado
|
||||
Regular expression not closed Expressão regular não fechada
|
||||
Template literal not closed Literal de modelo não fechado
|
||||
XML literal not closed Literal XML não fechado
|
||||
Bracket never closed Delimitador não fechado
|
||||
Closing bracket with no opening one Delimitador de fechamento sem abertura
|
||||
Syntax newer than ES3 Sintaxe mais recente que ES3
|
||||
Unknown directive Diretiva desconhecida
|
||||
Not valid UTF-8: unreadable bytes were replaced UTF-8 inválido: bytes ilegíveis foram substituídos
|
||||
Mixed line endings Quebras de linha misturadas
|
||||
Nesting too deep to follow Aninhamento profundo demais para seguir
|
||||
line {line} linha {line}
|
||||
lines {lines} linhas {lines}
|
||||
{file}, line {line} {file}, linha {line}
|
||||
Compiled script: ask its author for the .jsx file Script compilado: peça o arquivo .jsx ao autor
|
||||
Too large to check Grande demais para verificar
|
||||
Not a script for the reference app Não é um script para o aplicativo de referência
|
||||
Some lists were cut short at their limit. Algumas listas foram cortadas no limite.
|
||||
Check Spelling… Verificar Ortografia…
|
||||
Choose a folder Escolher uma pasta
|
||||
Choose… Escolher…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -367,6 +367,59 @@
|
||||
Character Style Options Параметры стиля символа
|
||||
Character Styles Стили символов
|
||||
Check Links Проверить связи
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Проверить сценарий
|
||||
Check Script… Проверить сценарий…
|
||||
Copy Report Копировать отчёт
|
||||
VectorCraft read this script without running it. VectorCraft прочитал этот сценарий, не запуская его.
|
||||
Needs nothing beyond plain JavaScript Нужен только обычный JavaScript
|
||||
Needs the script language's own objects or directives Нужны собственные объекты или директивы языка сценариев
|
||||
Needs the document model Нужна модель документа
|
||||
Needs files Нужны файлы
|
||||
Needs menu commands Нужны команды меню
|
||||
Needs dialogs Нужны диалоговые окна
|
||||
Needs features only the reference app has Нужны функции, которые есть только в эталонном приложении
|
||||
Outside the document Вне документа
|
||||
Writes, moves or deletes files Записывает, перемещает или удаляет файлы
|
||||
Opens files in other apps Открывает файлы в других приложениях
|
||||
Opens network connections Открывает сетевые подключения
|
||||
Loads native code Загружает нативный код
|
||||
Sends messages to other apps Отправляет сообщения другим приложениям
|
||||
Reads or sets environment variables Читает или задаёт переменные среды
|
||||
Runs code the checker can't read Выполняет код, который проверка не может прочитать
|
||||
Changes the reference app's preferences Изменяет настройки эталонного приложения
|
||||
Menu commands Команды меню
|
||||
Names the script builds as it runs: {count} Имена, которые сценарий составляет во время выполнения: {count}
|
||||
Document model Модель документа
|
||||
Object types: {names} Типы объектов: {names}
|
||||
Directives Директивы
|
||||
Includes Подключаемые файлы
|
||||
Checked Проверен
|
||||
Not followed: outside the script's folder Пропущен: вне папки сценария
|
||||
Includes itself Подключает сам себя
|
||||
Not read Не прочитан
|
||||
Not read: a limit was reached Не прочитан: достигнут предел
|
||||
Compiled or not text Скомпилирован или не является текстом
|
||||
Problems Проблемы
|
||||
String not closed Строка не закрыта
|
||||
Comment not closed Комментарий не закрыт
|
||||
Regular expression not closed Регулярное выражение не закрыто
|
||||
Template literal not closed Шаблонная строка не закрыта
|
||||
XML literal not closed XML-литерал не закрыт
|
||||
Bracket never closed Скобка не закрыта
|
||||
Closing bracket with no opening one Закрывающая скобка без открывающей
|
||||
Syntax newer than ES3 Синтаксис новее ES3
|
||||
Unknown directive Неизвестная директива
|
||||
Not valid UTF-8: unreadable bytes were replaced Недопустимый UTF-8: нечитаемые байты заменены
|
||||
Mixed line endings Смешанные концы строк
|
||||
Nesting too deep to follow Слишком глубокая вложенность
|
||||
line {line} строка {line}
|
||||
lines {lines} строки {lines}
|
||||
{file}, line {line} {file}, строка {line}
|
||||
Compiled script: ask its author for the .jsx file Скомпилированный сценарий: попросите у автора файл .jsx
|
||||
Too large to check Слишком большой для проверки
|
||||
Not a script for the reference app Это не сценарий для эталонного приложения
|
||||
Some lists were cut short at their limit. Некоторые списки обрезаны по пределу.
|
||||
Check Spelling… Проверить орфографию…
|
||||
Choose a folder Выберите папку
|
||||
Choose… Выбрать…
|
||||
@@ -3402,6 +3455,7 @@ axis Both Оба
|
||||
@msg Convert to Artboards: selection has no area Преобразование в монтажные области: у выделения нет области
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Преобразование в фигуру: не выделены прямоугольники или эллипсы
|
||||
@msg Copied the CSS of {rules} object(s) Скопирован CSS для {rules} объекта(ов)
|
||||
@msg Copied the script report Отчёт о сценарии скопирован
|
||||
@msg Corners edits the corners of a path «Углы» редактирует углы контура
|
||||
@msg Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e}
|
||||
@msg Couldn't open {name}: {e} Не удалось открыть {name}: {e}
|
||||
@@ -3893,6 +3947,7 @@ axis Both Оба
|
||||
@msg {name}: no such recovery entry {name}: такой записи восстановления нет
|
||||
@msg {path} can't be found: relink it first {path} не найден: сначала восстановите связь
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: здесь нет файловой системы (передайте dataBase64 для открытия; опустите путь, чтобы получить dataBase64)
|
||||
@msg {path}: not a file {path}: не файл
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} Не удалось поместить {name}: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -363,6 +363,59 @@
|
||||
Character Style Options Параметри стилю символу
|
||||
Character Styles Стилі символів
|
||||
Check Links Перевірити зв’язки
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Перевірити сценарій
|
||||
Check Script… Перевірити сценарій…
|
||||
Copy Report Копіювати звіт
|
||||
VectorCraft read this script without running it. VectorCraft прочитав цей сценарій, не запускаючи його.
|
||||
Needs nothing beyond plain JavaScript Потрібен лише звичайний JavaScript
|
||||
Needs the script language's own objects or directives Потрібні власні об’єкти або директиви мови сценаріїв
|
||||
Needs the document model Потрібна модель документа
|
||||
Needs files Потрібні файли
|
||||
Needs menu commands Потрібні команди меню
|
||||
Needs dialogs Потрібні діалогові вікна
|
||||
Needs features only the reference app has Потрібні функції, які має лише еталонна програма
|
||||
Outside the document Поза документом
|
||||
Writes, moves or deletes files Записує, переміщує або видаляє файли
|
||||
Opens files in other apps Відкриває файли в інших програмах
|
||||
Opens network connections Відкриває мережеві з’єднання
|
||||
Loads native code Завантажує нативний код
|
||||
Sends messages to other apps Надсилає повідомлення іншим програмам
|
||||
Reads or sets environment variables Читає або задає змінні середовища
|
||||
Runs code the checker can't read Виконує код, який перевірка не може прочитати
|
||||
Changes the reference app's preferences Змінює налаштування еталонної програми
|
||||
Menu commands Команди меню
|
||||
Names the script builds as it runs: {count} Імена, які сценарій складає під час виконання: {count}
|
||||
Document model Модель документа
|
||||
Object types: {names} Типи об’єктів: {names}
|
||||
Directives Директиви
|
||||
Includes Підключені файли
|
||||
Checked Перевірено
|
||||
Not followed: outside the script's folder Пропущено: поза папкою сценарію
|
||||
Includes itself Підключає сам себе
|
||||
Not read Не прочитано
|
||||
Not read: a limit was reached Не прочитано: досягнуто межі
|
||||
Compiled or not text Скомпільований або не текст
|
||||
Problems Проблеми
|
||||
String not closed Рядок не закрито
|
||||
Comment not closed Коментар не закрито
|
||||
Regular expression not closed Регулярний вираз не закрито
|
||||
Template literal not closed Шаблонний рядок не закрито
|
||||
XML literal not closed XML-літерал не закрито
|
||||
Bracket never closed Дужку не закрито
|
||||
Closing bracket with no opening one Закривна дужка без відкривної
|
||||
Syntax newer than ES3 Синтаксис, новіший за ES3
|
||||
Unknown directive Невідома директива
|
||||
Not valid UTF-8: unreadable bytes were replaced Недійсний UTF-8: нечитабельні байти замінено
|
||||
Mixed line endings Змішані кінці рядків
|
||||
Nesting too deep to follow Надто глибока вкладеність
|
||||
line {line} рядок {line}
|
||||
lines {lines} рядки {lines}
|
||||
{file}, line {line} {file}, рядок {line}
|
||||
Compiled script: ask its author for the .jsx file Скомпільований сценарій: попросіть в автора файл .jsx
|
||||
Too large to check Завеликий для перевірки
|
||||
Not a script for the reference app Це не сценарій для еталонної програми
|
||||
Some lists were cut short at their limit. Деякі списки обрізано на межі.
|
||||
Check Spelling… Перевірити орфографію…
|
||||
Choose a folder Виберіть папку
|
||||
Choose… Вибрати…
|
||||
@@ -3390,6 +3443,7 @@ axis Both Обидві
|
||||
@msg Convert to Artboards: selection has no area Перетворення на монтажні області: виділення не має області
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Перетворення на фігуру: не виділено прямокутники або еліпси
|
||||
@msg Copied the CSS of {rules} object(s) Скопійовано CSS об’єктів: {rules}
|
||||
@msg Copied the script report Звіт про сценарій скопійовано
|
||||
@msg Corners edits the corners of a path «Кути» редагує кути контуру
|
||||
@msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e}
|
||||
@msg Couldn't open {name}: {e} Не вдалося відкрити {name}: {e}
|
||||
@@ -3881,6 +3935,7 @@ axis Both Обидві
|
||||
@msg {name}: no such recovery entry {name}: такого запису відновлення немає
|
||||
@msg {path} can't be found: relink it first {path} не знайдено: спочатку відновіть зв’язок
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: тут немає файлової системи (передайте dataBase64 для відкриття; опустіть шлях, щоб отримати dataBase64)
|
||||
@msg {path}: not a file {path}: не файл
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} Не вдалося помістити {name}: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 713 and column 40.
|
@@ -446,6 +446,59 @@
|
||||
Character Styles Các kiểu ký tự
|
||||
Character: Ký tự:
|
||||
Check Links Kiểm tra liên kết
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script Kiểm tra script
|
||||
Check Script… Kiểm tra script…
|
||||
Copy Report Sao chép báo cáo
|
||||
VectorCraft read this script without running it. VectorCraft đã đọc script này mà không chạy nó.
|
||||
Needs nothing beyond plain JavaScript Chỉ cần JavaScript thông thường
|
||||
Needs the script language's own objects or directives Cần các đối tượng hoặc chỉ thị riêng của ngôn ngữ script
|
||||
Needs the document model Cần mô hình tài liệu
|
||||
Needs files Cần tệp
|
||||
Needs menu commands Cần lệnh menu
|
||||
Needs dialogs Cần hộp thoại
|
||||
Needs features only the reference app has Cần tính năng mà chỉ ứng dụng tham chiếu có
|
||||
Outside the document Bên ngoài tài liệu
|
||||
Writes, moves or deletes files Ghi, di chuyển hoặc xóa tệp
|
||||
Opens files in other apps Mở tệp trong ứng dụng khác
|
||||
Opens network connections Mở kết nối mạng
|
||||
Loads native code Tải mã gốc
|
||||
Sends messages to other apps Gửi thông điệp đến ứng dụng khác
|
||||
Reads or sets environment variables Đọc hoặc đặt biến môi trường
|
||||
Runs code the checker can't read Chạy mã mà trình kiểm tra không đọc được
|
||||
Changes the reference app's preferences Thay đổi tùy chọn của ứng dụng tham chiếu
|
||||
Menu commands Lệnh menu
|
||||
Names the script builds as it runs: {count} Tên do script tạo ra khi chạy: {count}
|
||||
Document model Mô hình tài liệu
|
||||
Object types: {names} Kiểu đối tượng: {names}
|
||||
Directives Chỉ thị
|
||||
Includes Tệp được bao gồm
|
||||
Checked Đã kiểm tra
|
||||
Not followed: outside the script's folder Bỏ qua: nằm ngoài thư mục của script
|
||||
Includes itself Tự bao gồm chính nó
|
||||
Not read Chưa đọc
|
||||
Not read: a limit was reached Chưa đọc: đã đạt giới hạn
|
||||
Compiled or not text Đã biên dịch hoặc không phải văn bản
|
||||
Problems Vấn đề
|
||||
String not closed Chuỗi chưa đóng
|
||||
Comment not closed Chú thích chưa đóng
|
||||
Regular expression not closed Biểu thức chính quy chưa đóng
|
||||
Template literal not closed Chuỗi mẫu chưa đóng
|
||||
XML literal not closed Hằng XML chưa đóng
|
||||
Bracket never closed Dấu ngoặc không được đóng
|
||||
Closing bracket with no opening one Dấu ngoặc đóng không có dấu ngoặc mở
|
||||
Syntax newer than ES3 Cú pháp mới hơn ES3
|
||||
Unknown directive Chỉ thị không xác định
|
||||
Not valid UTF-8: unreadable bytes were replaced UTF-8 không hợp lệ: các byte không đọc được đã bị thay thế
|
||||
Mixed line endings Kết thúc dòng lẫn lộn
|
||||
Nesting too deep to follow Lồng nhau quá sâu để theo dõi
|
||||
line {line} dòng {line}
|
||||
lines {lines} các dòng {lines}
|
||||
{file}, line {line} {file}, dòng {line}
|
||||
Compiled script: ask its author for the .jsx file Script đã biên dịch: hãy xin tác giả tệp .jsx
|
||||
Too large to check Quá lớn để kiểm tra
|
||||
Not a script for the reference app Không phải script cho ứng dụng tham chiếu
|
||||
Some lists were cut short at their limit. Một số danh sách đã bị cắt ngắn ở giới hạn của chúng.
|
||||
Check Spelling… Kiểm tra chính tả…
|
||||
Choose a folder Chọn thư mục
|
||||
Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Chọn tệp để liên kết lại từng tệp liên kết. Các tệp có kích thước và nội dung đã lưu cùng liên kết được hiển thị trước.
|
||||
@@ -3723,6 +3776,7 @@ theme Appearance Giao diện
|
||||
@msg Convert to Artboards: selection has no area Chuyển thành bảng vẽ: vùng chọn không có diện tích
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Chuyển thành hình dạng: chưa chọn hình chữ nhật hay hình elip
|
||||
@msg Copied the CSS of {rules} object(s) Đã sao chép CSS của {rules} đối tượng
|
||||
@msg Copied the script report Đã sao chép báo cáo script
|
||||
@msg Corners edits the corners of a path Corners chỉnh sửa các góc của đường dẫn
|
||||
@msg could not decode image: {0} không thể giải mã ảnh: {0}
|
||||
@msg Couldn't copy to the system clipboard: {e} Không thể sao chép vào khay nhớ tạm hệ thống: {e}
|
||||
@@ -4237,6 +4291,7 @@ theme Appearance Giao diện
|
||||
@msg {path} can't be found: relink it first {path} không tìm thấy: hãy liên kết lại trước
|
||||
@msg {path} is not a folder {path} không phải là thư mục
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: không có hệ thống tệp ở đây (truyền dataBase64 để mở; bỏ path để nhận dataBase64)
|
||||
@msg {path}: not a file {path}: không phải là tệp
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3643 and column 10.
|
@@ -330,6 +330,59 @@
|
||||
Character Style Options 字符样式选项
|
||||
Character Styles 字符样式
|
||||
Check Links 检查链接
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script 检查脚本
|
||||
Check Script… 检查脚本…
|
||||
Copy Report 复制报告
|
||||
VectorCraft read this script without running it. VectorCraft 读取了此脚本,但没有运行它。
|
||||
Needs nothing beyond plain JavaScript 只需要普通的 JavaScript
|
||||
Needs the script language's own objects or directives 需要脚本语言自身的对象或指令
|
||||
Needs the document model 需要文档模型
|
||||
Needs files 需要文件
|
||||
Needs menu commands 需要菜单命令
|
||||
Needs dialogs 需要对话框
|
||||
Needs features only the reference app has 需要只有参考应用才有的功能
|
||||
Outside the document 文档之外
|
||||
Writes, moves or deletes files 写入、移动或删除文件
|
||||
Opens files in other apps 在其他应用中打开文件
|
||||
Opens network connections 打开网络连接
|
||||
Loads native code 加载本机代码
|
||||
Sends messages to other apps 向其他应用发送消息
|
||||
Reads or sets environment variables 读取或设置环境变量
|
||||
Runs code the checker can't read 运行检查无法读取的代码
|
||||
Changes the reference app's preferences 更改参考应用的首选项
|
||||
Menu commands 菜单命令
|
||||
Names the script builds as it runs: {count} 脚本在运行时组成的名称:{count}
|
||||
Document model 文档模型
|
||||
Object types: {names} 对象类型:{names}
|
||||
Directives 指令
|
||||
Includes 包含的文件
|
||||
Checked 已检查
|
||||
Not followed: outside the script's folder 已跳过:在脚本文件夹之外
|
||||
Includes itself 包含自身
|
||||
Not read 未读取
|
||||
Not read: a limit was reached 未读取:已达到上限
|
||||
Compiled or not text 已编译或不是文本
|
||||
Problems 问题
|
||||
String not closed 字符串未闭合
|
||||
Comment not closed 注释未闭合
|
||||
Regular expression not closed 正则表达式未闭合
|
||||
Template literal not closed 模板字面量未闭合
|
||||
XML literal not closed XML 字面量未闭合
|
||||
Bracket never closed 括号未闭合
|
||||
Closing bracket with no opening one 闭合括号没有对应的左括号
|
||||
Syntax newer than ES3 比 ES3 更新的语法
|
||||
Unknown directive 未知指令
|
||||
Not valid UTF-8: unreadable bytes were replaced 不是有效的 UTF-8:无法读取的字节已被替换
|
||||
Mixed line endings 换行符混用
|
||||
Nesting too deep to follow 嵌套过深,无法跟踪
|
||||
line {line} 第 {line} 行
|
||||
lines {lines} 第 {lines} 行
|
||||
{file}, line {line} {file},第 {line} 行
|
||||
Compiled script: ask its author for the .jsx file 已编译的脚本:请向作者索取 .jsx 文件
|
||||
Too large to check 太大,无法检查
|
||||
Not a script for the reference app 不是参考应用的脚本
|
||||
Some lists were cut short at their limit. 部分列表已在上限处截断。
|
||||
Check Spelling… 检查拼写…
|
||||
Choose a folder 选择文件夹
|
||||
Choose… 选择…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -342,6 +342,59 @@
|
||||
Character Style Options 字元樣式選項
|
||||
Character Styles 字元樣式
|
||||
Check Links 檢查連結
|
||||
# Check Script (File › Scripts) and the report it shows on a script written for the reference app.
|
||||
Check Script 檢查指令碼
|
||||
Check Script… 檢查指令碼…
|
||||
Copy Report 複製報告
|
||||
VectorCraft read this script without running it. VectorCraft 已讀取此指令碼,但沒有執行。
|
||||
Needs nothing beyond plain JavaScript 只需要一般的 JavaScript
|
||||
Needs the script language's own objects or directives 需要指令碼語言本身的物件或指示詞
|
||||
Needs the document model 需要文件模型
|
||||
Needs files 需要檔案
|
||||
Needs menu commands 需要選單指令
|
||||
Needs dialogs 需要對話框
|
||||
Needs features only the reference app has 需要只有參考應用程式才有的功能
|
||||
Outside the document 文件以外
|
||||
Writes, moves or deletes files 寫入、移動或刪除檔案
|
||||
Opens files in other apps 在其他應用程式中開啟檔案
|
||||
Opens network connections 開啟網路連線
|
||||
Loads native code 載入原生程式碼
|
||||
Sends messages to other apps 傳送訊息給其他應用程式
|
||||
Reads or sets environment variables 讀取或設定環境變數
|
||||
Runs code the checker can't read 執行檢查無法讀取的程式碼
|
||||
Changes the reference app's preferences 變更參考應用程式的偏好設定
|
||||
Menu commands 選單指令
|
||||
Names the script builds as it runs: {count} 指令碼在執行時組成的名稱:{count}
|
||||
Document model 文件模型
|
||||
Object types: {names} 物件類型:{names}
|
||||
Directives 指示詞
|
||||
Includes 引入的檔案
|
||||
Checked 已檢查
|
||||
Not followed: outside the script's folder 已略過:在指令碼資料夾以外
|
||||
Includes itself 引入自身
|
||||
Not read 未讀取
|
||||
Not read: a limit was reached 未讀取:已達上限
|
||||
Compiled or not text 已編譯或不是文字
|
||||
Problems 問題
|
||||
String not closed 字串未結束
|
||||
Comment not closed 註解未結束
|
||||
Regular expression not closed 規則運算式未結束
|
||||
Template literal not closed 樣板字面值未結束
|
||||
XML literal not closed XML 字面值未結束
|
||||
Bracket never closed 括號未關閉
|
||||
Closing bracket with no opening one 結束括號沒有對應的開始括號
|
||||
Syntax newer than ES3 比 ES3 更新的語法
|
||||
Unknown directive 不明的指示詞
|
||||
Not valid UTF-8: unreadable bytes were replaced 不是有效的 UTF-8:無法讀取的位元組已被取代
|
||||
Mixed line endings 換行字元混用
|
||||
Nesting too deep to follow 巢狀層次太深,無法追蹤
|
||||
line {line} 第 {line} 行
|
||||
lines {lines} 第 {lines} 行
|
||||
{file}, line {line} {file},第 {line} 行
|
||||
Compiled script: ask its author for the .jsx file 已編譯的指令碼:請向作者索取 .jsx 檔案
|
||||
Too large to check 太大,無法檢查
|
||||
Not a script for the reference app 不是參考應用程式的指令碼
|
||||
Some lists were cut short at their limit. 部分清單已在上限處截斷。
|
||||
Check Spelling… 拼字檢查…
|
||||
Choose a folder 選擇資料夾
|
||||
Choose… 選擇…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -16,8 +16,10 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"
|
||||
|
||||
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
|
||||
/// graphic style library files open in the library panel, Libraries panel files are added to it,
|
||||
/// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its
|
||||
/// `warnings` say what didn't come in as it was), else null.
|
||||
/// flattener, PDF, print and perspective grid presets files are imported, and a script written for
|
||||
/// the reference app (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). →
|
||||
/// `document.open`'s result for a document opened now (its `warnings` say what didn't come in as it
|
||||
/// was), else null.
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
|
||||
let opens = opens_as(name);
|
||||
// A WebAssembly plug-in is installed.
|
||||
@@ -27,6 +29,13 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
||||
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if opens == Some(OpensAs::Script) {
|
||||
let p = match path {
|
||||
Some(path) => json!({ "path": path }),
|
||||
None => json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}),
|
||||
};
|
||||
return dialogs::script_check::command(app, &p).map(|_| Value::Null);
|
||||
}
|
||||
if let Some(OpensAs::Presets { import, what }) = opens {
|
||||
let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
|
||||
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
@@ -57,12 +66,14 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
||||
open_document(app, name, bytes, path, &Value::Null)
|
||||
}
|
||||
|
||||
/// The kinds of file File → Open installs, imports or shows in the library panel or the Libraries
|
||||
/// panel ([`opens_as`]).
|
||||
/// The kinds of file File → Open installs, imports, checks or shows in the library panel or the
|
||||
/// Libraries panel ([`opens_as`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum OpensAs {
|
||||
/// A WebAssembly plug-in: installed.
|
||||
Plugin,
|
||||
/// A script written for the reference app: checked without running it (Check Script…).
|
||||
Script,
|
||||
/// Flattener, PDF, print or perspective grid presets: imported with command `import`, and
|
||||
/// `what` names them in the status bar.
|
||||
Presets { import: &'static str, what: &'static str },
|
||||
@@ -74,9 +85,9 @@ pub enum OpensAs {
|
||||
StyleLibrary,
|
||||
}
|
||||
|
||||
/// The kind of file named `name` (by its extension) that File → Open installs, imports or shows in
|
||||
/// the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens these files
|
||||
/// the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]).
|
||||
/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks or
|
||||
/// shows in the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens
|
||||
/// these files the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]).
|
||||
pub fn opens_as(name: &str) -> Option<OpensAs> {
|
||||
let ext = fileio::extension(name);
|
||||
let presets = [
|
||||
@@ -87,6 +98,8 @@ pub fn opens_as(name: &str) -> Option<OpensAs> {
|
||||
];
|
||||
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
|
||||
Some(OpensAs::Plugin)
|
||||
} else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) {
|
||||
Some(OpensAs::Script)
|
||||
} else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
|
||||
Some(OpensAs::Presets { import, what })
|
||||
} else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
|
||||
@@ -177,7 +190,13 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<Value, String>
|
||||
|
||||
/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks
|
||||
/// first) is decoded off the UI thread ([`crate::opening::start`]).
|
||||
fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result<Value, String> {
|
||||
pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result<Value, String> {
|
||||
// A script is read by `script.check`, up to its limit.
|
||||
if opens_as(path) == Some(OpensAs::Script) {
|
||||
dialogs::script_check::command(app, &json!({ "path": path }))?;
|
||||
note_recent(app, path);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let mut bytes = read(app, path)?;
|
||||
if background && opens_as(path).is_none() {
|
||||
// A PDF with several pages or a password, or a DXF drawing, asks first, as at once.
|
||||
|
||||
@@ -130,6 +130,8 @@ mod tests_saveext;
|
||||
#[cfg(test)]
|
||||
mod tests_screenmode;
|
||||
#[cfg(test)]
|
||||
mod tests_script_check;
|
||||
#[cfg(test)]
|
||||
mod tests_scrub;
|
||||
#[cfg(test)]
|
||||
mod tests_selectall;
|
||||
|
||||
@@ -763,6 +763,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{path?: a .wasm file} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too)",
|
||||
),
|
||||
(
|
||||
"ui.checkScript",
|
||||
"Check Script…",
|
||||
"",
|
||||
"{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)",
|
||||
),
|
||||
(
|
||||
"ui.perspectiveGridDialog",
|
||||
"Define Perspective Grid…",
|
||||
@@ -1434,6 +1440,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p),
|
||||
"plugin.dialog" => crate::dialogs::plugin::open(app, p),
|
||||
"ui.installPlugin" => io::install_plugin(app, s("path")),
|
||||
"ui.checkScript" => crate::dialogs::script_check::command(app, p),
|
||||
"ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app),
|
||||
"ui.perspectivePresetsDialog" => {
|
||||
crate::dialogs::perspective_presets::open(app, s("selected").as_deref());
|
||||
@@ -2087,7 +2094,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Export Selection…", "file.exportSelection"),
|
||||
Sep,
|
||||
c("Package…", "ui.packageDialog"),
|
||||
sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]),
|
||||
sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]),
|
||||
Sep,
|
||||
c("Document Setup…", "file.documentSetup"),
|
||||
sub(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips,
|
||||
//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. While the
|
||||
//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead.
|
||||
//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead, and with only
|
||||
//! ruler guides selected their X (vertical guides) and Y (horizontal ones).
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
@@ -94,6 +95,39 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
|
||||
});
|
||||
}
|
||||
|
||||
/// With ruler guides selected and no art: the X of the vertical ones and the Y of the horizontal
|
||||
/// ones (the least of each; none where no such guide is selected).
|
||||
fn selected_guides(app: &VectorcraftApp) -> Option<(Option<f64>, Option<f64>)> {
|
||||
let st = app.session.active()?;
|
||||
if !st.selection.objects.is_empty() || st.selection.guides.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let least = |vertical: bool| {
|
||||
st.selection.guides.iter().filter_map(|i| st.doc.guides.get(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp)
|
||||
};
|
||||
Some((least(true), least(false)))
|
||||
}
|
||||
|
||||
/// The selected ruler guides' view of the panel (#1597): X moves the vertical ones and Y the
|
||||
/// horizontal ones, together.
|
||||
fn guide_fields(app: &mut VectorcraftApp, ui: &mut Ui, (x, y): (Option<f64>, Option<f64>)) {
|
||||
let units = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
egui::Grid::new("xfp-guide-grid").num_columns(2).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
for (label, id, pos, origin, by) in [("X:", "xfp-guide-x", x, origin.x, "dx"), ("Y:", "xfp-guide-y", y, origin.y, "dy")] {
|
||||
widgets::dim_label(ui, label);
|
||||
ui.add_enabled_ui(pos.is_some(), |ui| {
|
||||
if let (Some(v), Some(pos)) = (widgets::num_field(ui, id, pos.map(|p| p - origin), units, 80.0), pos)
|
||||
&& let Err(e) = app.run("guide.move", json!({ by: v + origin - pos }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if app.session.active().is_none() {
|
||||
@@ -104,6 +138,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
artboard_fields(app, ui, i, r);
|
||||
return;
|
||||
}
|
||||
if let Some(guides) = selected_guides(app) {
|
||||
guide_fields(app, ui, guides);
|
||||
return;
|
||||
}
|
||||
let units = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
// The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference
|
||||
@@ -426,6 +464,28 @@ mod tests {
|
||||
assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}");
|
||||
}
|
||||
|
||||
/// #1597: with only ruler guides selected the panel shows their X or Y, and nothing of the art.
|
||||
#[test]
|
||||
fn selected_guides_show_their_position() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": false, "pos": 45})).unwrap();
|
||||
app.run("guide.select", json!({"indexes": [0]})).unwrap();
|
||||
assert_eq!(selected_guides(&app), Some((Some(120.0), None)));
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && !shown.iter().any(|t| t == "W:" || t == "45 pt"), "{shown:?}");
|
||||
app.run("guide.select", json!({"indexes": [0, 1]})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "45 pt"), "{shown:?}");
|
||||
// The field's edit: the vertical guide moves, the horizontal one stays.
|
||||
app.run("guide.move", json!({"dx": 30})).unwrap();
|
||||
assert_eq!(selected_guides(&app), Some((Some(150.0), Some(45.0))));
|
||||
// Art selected with them: the art's box, as before.
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
assert_eq!(selected_guides(&app), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_fields_keep_the_reference_point() {
|
||||
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
|
||||
|
||||
@@ -61,6 +61,7 @@ const DOCUMENT_FREE: &[&str] = &[
|
||||
"file.open",
|
||||
"file.newFromTemplate",
|
||||
"ui.installPlugin",
|
||||
"ui.checkScript",
|
||||
"window.swatchLibrary.other",
|
||||
"window.graphicStyleLibrary.other",
|
||||
"shortcuts.import",
|
||||
|
||||
@@ -301,8 +301,8 @@ impl VectorcraftApp {
|
||||
/// Where file `name` dropped with the pointer at `pos` (screen points) goes: as in Illustrator,
|
||||
/// onto the canvas of the open document (Shift embeds it), else opened. Where the platform
|
||||
/// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its
|
||||
/// own and a picture or text is placed in the middle of the view. A library, a presets file or
|
||||
/// a plug-in opens wherever it is dropped, as File → Open opens it ([`io::opens_as`]).
|
||||
/// own and a picture or text is placed in the middle of the view. A library, a presets file, a
|
||||
/// plug-in or a script opens wherever it is dropped, as File → Open opens it ([`io::opens_as`]).
|
||||
pub fn drop_target(&self, name: &str, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
|
||||
let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open };
|
||||
let at = match pos {
|
||||
|
||||
@@ -141,8 +141,9 @@ fn a_drop_without_a_position_opens_documents_and_places_pictures() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Swatch libraries and color books, graphic style libraries, Libraries panel files, presets and
|
||||
/// plug-ins open as File → Open opens them, dropped with or without a position, on the canvas too.
|
||||
/// Swatch libraries and color books, graphic style libraries, Libraries panel files, presets,
|
||||
/// plug-ins and scripts open as File → Open opens them, dropped with or without a position, on the
|
||||
/// canvas too.
|
||||
#[test]
|
||||
fn libraries_presets_and_plugins_open_wherever_they_are_dropped() {
|
||||
let mut app = app();
|
||||
@@ -150,7 +151,7 @@ fn libraries_presets_and_plugins_open_wherever_they_are_dropped() {
|
||||
let rect = laid_out(&mut app, &ctx);
|
||||
let on_canvas = rect.center() + vec2(60.0, -40.0);
|
||||
let libraries = ["a.vcswatches", "a.gpl", "a.ase", "a.acb", "a.vcstyles", "a.vclibrary"];
|
||||
let presets_and_plugins = ["a.vcflattener", "a.vcpdfpresets", "a.vcprintpresets", "a.vcperspective", "a.wasm"];
|
||||
let presets_and_plugins = ["a.vcflattener", "a.vcpdfpresets", "a.vcprintpresets", "a.vcperspective", "a.wasm", "a.jsx", "a.jsxinc", "a.jsxbin"];
|
||||
for name in libraries.into_iter().chain(presets_and_plugins) {
|
||||
assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}");
|
||||
assert_eq!(app.drop_target(name, Some(on_canvas), true), DropTarget::Open, "{name} on the canvas");
|
||||
@@ -175,6 +176,24 @@ fn a_dropped_swatch_library_opens_in_the_library_panel() {
|
||||
assert_eq!(app.ui.recent_files.first(), Some(&gpl), "added to Open Recent Files as File → Open adds it");
|
||||
}
|
||||
|
||||
/// A script dropped on the window while a document is open shows its check, as File → Open shows
|
||||
/// it, and the document is left as it was.
|
||||
#[test]
|
||||
fn a_dropped_script_shows_its_check() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
laid_out(&mut app, &ctx);
|
||||
let path = temp_file("dropped.jsx", b"#target acme\napp.executeMenuCommand('acmecopyall');\n");
|
||||
frame(&mut app, &ctx, vec![], &[&path], false);
|
||||
let d = app.ui.dialog.as_ref().expect("the check is shown");
|
||||
assert_eq!(d.kind, crate::dialogs::script_check::KIND, "{}", app.ui.status);
|
||||
assert_eq!(
|
||||
(d.fields["report"]["name"].as_str(), d.fields["report"]["needs"].clone()),
|
||||
(Some("dropped.jsx"), json!(["language", "menuCommands"]))
|
||||
);
|
||||
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
|
||||
}
|
||||
|
||||
/// A Libraries panel file dropped on the window while a document is open is added to the
|
||||
/// Libraries panel as a new library, as File → Open adds it.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! File › Scripts › Check Script… (`ui.checkScript`): the report on a script written for the reference
|
||||
//! app, read without running it, and its Copy Report; File › Open of `.jsx`, `.jsxinc` and `.jsxbin`
|
||||
//! files.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, dialogs, io, menus, theme};
|
||||
|
||||
/// An app reading files from disk.
|
||||
fn app() -> VectorcraftApp {
|
||||
let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
|
||||
VectorcraftApp::new(Session::new(), services)
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A script with a little of everything the report shows.
|
||||
const SCRIPT: &str = concat!(
|
||||
"#target acme\n",
|
||||
"#include 'lib/missing.jsxinc'\n",
|
||||
"var doc = app.activeDocument;\n",
|
||||
"app.executeMenuCommand('acmecopyall');\n",
|
||||
"app.executeMenuCommand(name);\n",
|
||||
"var f = new File(p); f.open('w');\n",
|
||||
"if (x.typename == 'AcmeShape') {}\n",
|
||||
"var s = 'open;\n",
|
||||
);
|
||||
|
||||
fn report(app: &VectorcraftApp) -> &Value {
|
||||
let d = app.ui.dialog.as_ref().expect("the report is shown");
|
||||
assert_eq!(d.kind, dialogs::script_check::KIND);
|
||||
&d.fields["report"]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_scripts_holds_check_script_above_other_script() {
|
||||
let tree = menus::menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let scripts = file.iter().find_map(|i| if let menus::Item::Sub("Scripts", items) = i { Some(items) } else { None }).unwrap();
|
||||
assert!(
|
||||
matches!(scripts.as_slice(), [menus::Item::Cmd("Check Script…", "ui.checkScript", _), menus::Item::Todo("Other Script…", _)]),
|
||||
"{scripts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_script_shows_the_report_and_copy_report_copies_it() {
|
||||
let mut app = app();
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(SCRIPT.as_bytes()), "name": "Tidy.jsx"});
|
||||
let r = menus::run_ui_command(&mut app, "ui.checkScript", &p).unwrap().unwrap();
|
||||
assert_eq!(report(&app), &r);
|
||||
assert_eq!((r["name"].as_str(), r["kind"].as_str()), (Some("Tidy.jsx"), Some("script")));
|
||||
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands"]));
|
||||
// The window draws every section.
|
||||
frame(&mut app);
|
||||
assert_eq!(report(&app), &r);
|
||||
// Copy Report (the button beside Close) copies the report as JSON and leaves the window open.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let copied: Value = serde_json::from_str(app.clipboard_out.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(copied, r);
|
||||
assert!(app.ui.dialog.as_ref().unwrap().fields.get("discard").is_none());
|
||||
assert_eq!(app.ui.status, "Copied the script report");
|
||||
// Close closes it.
|
||||
dialogs::cancel(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
// The script's text does as well as its bytes.
|
||||
let by_text = menus::run_ui_command(&mut app, "ui.checkScript", &json!({"data": SCRIPT, "name": "Tidy.jsx"})).unwrap().unwrap();
|
||||
assert_eq!(by_text, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_kind_of_file_draws_its_verdict() {
|
||||
for (name, bytes, kind) in [
|
||||
("a.jsxbin", b"anything".as_slice(), "compiled"),
|
||||
("a.jsx", b"import a from 'a';\n".as_slice(), "module"),
|
||||
("a.jsx", b"\0\0\0".as_slice(), "notText"),
|
||||
("a.jsx", b"".as_slice(), "script"),
|
||||
] {
|
||||
let mut app = app();
|
||||
io::open_bytes(&mut app, name, bytes, None).unwrap();
|
||||
assert_eq!(report(&app)["kind"], kind, "{name}");
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_open_of_a_script_checks_it_with_what_it_includes() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-ui-script-check-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("lib")).unwrap();
|
||||
std::fs::write(dir.join("main.jsx"), "#include 'lib/net.jsxinc'\n").unwrap();
|
||||
std::fs::write(dir.join("lib/net.jsxinc"), "var s = new Socket();\n").unwrap();
|
||||
let path = dir.join("main.jsx").to_string_lossy().into_owned();
|
||||
let mut opened = app();
|
||||
let reads = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let counted = reads.clone();
|
||||
opened.services.read = Some(Box::new(move |p: &str| {
|
||||
counted.set(counted.get() + 1);
|
||||
std::fs::read(p).map_err(|e| e.to_string())
|
||||
}));
|
||||
io::open_path(&mut opened, &path).unwrap();
|
||||
let r = report(&opened);
|
||||
assert_eq!(r["includes"], json!([{"value": "lib/net.jsxinc", "line": 1, "status": "checked", "path": "lib/net.jsxinc"}]));
|
||||
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
|
||||
assert!(opened.session.documents().is_empty(), "no document opened");
|
||||
// `script.check` reads the script up to its limit, and the app's reader, which reads a whole file,
|
||||
// isn't asked.
|
||||
assert_eq!(reads.get(), 0);
|
||||
assert_eq!(opened.ui.recent_files.first(), Some(&path), "File › Open lists it in Open Recent Files");
|
||||
// Check Script… with a path does the same; without one it asks for a script.
|
||||
let mut app = app();
|
||||
menus::run_ui_command(&mut app, "ui.checkScript", &json!({ "path": path })).unwrap().unwrap();
|
||||
assert_eq!(report(&app)["includes"][0]["status"], "checked");
|
||||
let asked = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
|
||||
let seen = asked.clone();
|
||||
let picked = path.clone();
|
||||
app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| {
|
||||
seen.borrow_mut().push(pick.filters.clone());
|
||||
Some(picked.clone())
|
||||
}));
|
||||
app.ui.dialog = None;
|
||||
menus::run_ui_command(&mut app, "ui.checkScript", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(asked.borrow().as_slice(), [vec![("Scripts", &["jsx", "jsxinc", "jsxbin"][..])]]);
|
||||
assert_eq!(report(&app)["name"], "main.jsx");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Where a document opens off the UI thread (File › Open, Open Recent and drops on the desktop), a
|
||||
/// script shows its report at once, and the app's reader isn't asked for it.
|
||||
#[test]
|
||||
fn a_script_opens_at_once_where_documents_open_off_the_ui_thread() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-ui-script-background-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("later.jsx");
|
||||
std::fs::write(&path, "alert('later');\n").unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let mut opened = app();
|
||||
let reads = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let counted = reads.clone();
|
||||
opened.services.read = Some(Box::new(move |p: &str| {
|
||||
counted.set(counted.get() + 1);
|
||||
std::fs::read(p).map_err(|e| e.to_string())
|
||||
}));
|
||||
assert_eq!(io::open_file(&mut opened, &path, true).unwrap(), Value::Null);
|
||||
assert!(opened.opening.pending.is_empty(), "nothing is read off the UI thread");
|
||||
assert_eq!(report(&opened)["name"], "later.jsx");
|
||||
assert_eq!(reads.get(), 0);
|
||||
assert_eq!(opened.ui.recent_files.first(), Some(&path));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Check Script… asks for its script off the UI thread, and acts on no document: the answer shows the
|
||||
/// report even when the document that was active closed meanwhile.
|
||||
#[test]
|
||||
fn check_script_asks_off_the_ui_thread_and_needs_no_document() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-ui-script-pick-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("picked.jsx");
|
||||
std::fs::write(&path, "alert('picked');\n").unwrap();
|
||||
let mut services = crate::Services { dialogs_are_sheets: false, ..Default::default() };
|
||||
let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut services);
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
assert!(app.run("ui.checkScript", json!({})).is_err(), "nothing yet: the dialog is open");
|
||||
assert!(
|
||||
matches!(shown.requests().as_slice(), [crate::picks::PickRequest::Open(pick)] if pick.filters == [("Scripts", &["jsx", "jsxinc", "jsxbin"][..])])
|
||||
);
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
shown.answer(&[&path.to_string_lossy()]);
|
||||
crate::picks::poll(&mut app, &egui::Context::default());
|
||||
assert_eq!(report(&app)["name"], "picked.jsx", "{}", app.ui.status);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_id_a_report_holds_has_a_label() {
|
||||
use dialogs::script_check::{need_label, outside_label, problem_label, status_label};
|
||||
use vectorcraft_engine::cmd::scripts::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
|
||||
for need in Need::ALL {
|
||||
assert!(need_label(need.id()).is_some(), "{need:?}");
|
||||
}
|
||||
for status in IncludeStatus::ALL {
|
||||
assert!(status_label(status.id()).is_some(), "{status:?}");
|
||||
}
|
||||
for id in OUTSIDE {
|
||||
assert!(outside_label(id).is_some(), "{id}");
|
||||
}
|
||||
for code in PROBLEMS {
|
||||
assert!(problem_label(code).is_some(), "{code}");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar (its
|
||||
//! All Tools drawer and the toolbar edits it makes: `edit_toolbar`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -608,9 +610,12 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating
|
||||
/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked.
|
||||
fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
|
||||
let mut chosen = None;
|
||||
let w = 250.0;
|
||||
for id in tools {
|
||||
let Some(tool) = tool_info(id) else { continue };
|
||||
let lang = crate::i18n::current();
|
||||
let color = |tool: &ToolInfo| if active == tool.id { t.flyout_active } else { t.text_strong };
|
||||
let rows: Vec<(&ToolInfo, RowText)> =
|
||||
tools.iter().filter_map(|id| tool_info(id)).map(|tool| (tool, row_text(ui, lang, tool, color(tool)))).collect();
|
||||
let w = rows_width(rows.iter().map(|(_, text)| text));
|
||||
for (tool, (name, shortcut)) in rows {
|
||||
let is_active = active == tool.id;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
@@ -621,16 +626,10 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<
|
||||
}
|
||||
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
|
||||
let color = if is_active { t.flyout_active } else { t.text_strong };
|
||||
ui.painter().text(r.left_center() + vec2(52.0, 0.0), egui::Align2::LEFT_CENTER, tl!(tool.label), egui::FontId::proportional(13.0), color);
|
||||
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(18.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
format!("({sc})"),
|
||||
egui::FontId::proportional(13.0),
|
||||
color,
|
||||
);
|
||||
let color = color(tool);
|
||||
ui.painter().galley(pos2(r.left() + NAME_X, r.center().y - name.size().y / 2.0), name, color);
|
||||
if let Some(sc) = shortcut {
|
||||
ui.painter().galley(pos2(r.right() - ROW_END - sc.size().x, r.center().y - sc.size().y / 2.0), sc, color);
|
||||
}
|
||||
if resp.clicked() {
|
||||
chosen = Some(tool.id);
|
||||
@@ -639,6 +638,27 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<
|
||||
chosen
|
||||
}
|
||||
|
||||
/// A flyout row's name and its shortcut, laid out.
|
||||
type RowText = (Arc<egui::Galley>, Option<Arc<egui::Galley>>);
|
||||
|
||||
/// Where a flyout row's name starts, the least gap between it and the shortcut, and the room right
|
||||
/// of the shortcut.
|
||||
const NAME_X: f32 = 52.0;
|
||||
const NAME_GAP: f32 = 14.0;
|
||||
const ROW_END: f32 = 18.0;
|
||||
|
||||
/// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them.
|
||||
fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText {
|
||||
let lay = |s: String| ui.painter().layout_no_wrap(s, egui::FontId::proportional(13.0), color);
|
||||
(lay(crate::i18n::tr(lang, tool.label).to_string()), crate::shortcut_editor::tool_shortcut(tool.id).map(|sc| lay(format!("({sc})"))))
|
||||
}
|
||||
|
||||
/// The width of a flyout's rows: 250 pt, or what its longest name needs to stay clear of its
|
||||
/// shortcut (translated names are far longer than the English ones, #1616).
|
||||
fn rows_width<'a>(rows: impl Iterator<Item = &'a RowText>) -> f32 {
|
||||
rows.map(|(name, sc)| NAME_X + name.size().x + sc.as_ref().map_or(0.0, |sc| NAME_GAP + sc.size().x) + ROW_END).fold(250.0, f32::max)
|
||||
}
|
||||
|
||||
/// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag
|
||||
/// that tears the flyout off carries on moving it).
|
||||
fn bar_id(key: &str) -> egui::Id {
|
||||
@@ -1011,6 +1031,36 @@ pub(crate) mod tests {
|
||||
assert_eq!((b.x0, b.y0), (35.0, 10.0));
|
||||
}
|
||||
|
||||
/// #1616: in every language a flyout is wide enough that no tool's name runs into its shortcut
|
||||
/// (the Spanish Live Paint names need more than the 250 pt the English ones fit in).
|
||||
#[test]
|
||||
fn a_flyout_is_as_wide_as_its_longest_translated_row() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
for lang in crate::i18n::Lang::all() {
|
||||
for group in TOOL_GROUPS {
|
||||
let rows: Vec<RowText> = group.iter().map(|tool| row_text(ui, lang, tool, Color32::WHITE)).collect();
|
||||
let w = rows_width(rows.iter());
|
||||
assert!(w >= 250.0, "{lang:?}");
|
||||
for (tool, (name, sc)) in group.iter().zip(&rows) {
|
||||
let name_end = NAME_X + name.size().x;
|
||||
let shortcut_start = w - ROW_END - sc.as_ref().map_or(0.0, |sc| sc.size().x);
|
||||
assert!(
|
||||
name_end <= shortcut_start + 0.01,
|
||||
"{lang:?} {}: the name ends at {name_end}, the shortcut starts at {shortcut_start}",
|
||||
tool.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let es = crate::i18n::Lang::from_code("es").unwrap();
|
||||
let live_paint = TOOL_GROUPS.iter().find(|g| g.iter().any(|t| t.id == "livePaintSelection")).expect("the Live Paint group");
|
||||
let rows: Vec<RowText> = live_paint.iter().map(|tool| row_text(ui, es, tool, Color32::WHITE)).collect();
|
||||
assert!(rows_width(rows.iter()) > 250.0, "the Spanish Live Paint flyout is wider than the English one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicking_a_tool_group_opens_its_flyout() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
+134
-130
@@ -1,6 +1,6 @@
|
||||
# Image Trace quality
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (levelling moves no line end more than 1.6 px: Technical Drawing's largest distance 4.21 px to 1.46)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Flat Logo on scans: 470 paths and 21 px off to 8.5 paths and 0.09 px)
|
||||
|
||||
What Image Trace (`crates/trace`) makes of images whose right answer is known, measured by a harness and
|
||||
compared with the numbers before each change. Nothing here is a judgement by eye, and nothing is compared
|
||||
@@ -57,7 +57,10 @@ The letters are drawn by hand from lines and curves: the repository has no font
|
||||
| outside px | how far the trace reaches outside the image: a curve that strays |
|
||||
|
||||
Black-and-white presets trace black: on the scanned variant their ΔE includes the paper's and ink's own
|
||||
colours. `–` means the measure does not apply; `all` that nothing was traced.
|
||||
colours. Silhouettes has its threshold at 200, not 128: its outline lies where the image
|
||||
crosses that tone, about 0.3 px outside the true edge on anti-aliased art and 0.8 px on blurred art; with
|
||||
the threshold at 128 the same preset is 0.110 px off on its inputs (1.00 px at most), as the other
|
||||
black-and-white presets are. `–` means the measure does not apply; `all` that nothing was traced.
|
||||
|
||||
## Baseline and now
|
||||
|
||||
@@ -81,7 +84,7 @@ Each preset on the inputs it is meant for, all sizes and variants.
|
||||
| Technical Drawing | polygons, star, letter A, lines, askew shapes and lines | 2.73 | 13.67 | 0.210 | 1.43 | 105.76 | 4.7 | 31 | 2.01 | 100 | 9 | 85 | 5.9 | – | – | – | – | 0.1 | 0.0 | – | – | – |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.43 | 3.53 | 3.106 | 88.50 | 0.78 | 69.9 | 352 | 38.92 | 38 | 10 | 80 | 237.0 | 0.0 | 0 | 13.8 | – | 59.1 | 0.2 | – | – | – |
|
||||
|
||||
### Now (with levelling bounded)
|
||||
### Now (with Flat Logo's tones folded)
|
||||
|
||||
| preset | inputs | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | grouped % | gradient paths | found % | angle off | ramp off |
|
||||
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
|
||||
@@ -99,7 +102,7 @@ Each preset on the inputs it is meant for, all sizes and variants.
|
||||
| Line Art | lines | 2.62 | 9.49 | 0.056 | 0.99 | 105.72 | 5.4 | 43 | 2.14 | 40 | 0 | – | – | – | – | – | – | 0.1 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Technical Drawing | polygons, star, letter A, lines, askew shapes and lines | 2.65 | 10.88 | 0.126 | 1.46 | 105.64 | 4.7 | 38 | 2.55 | 81 | 19 | 78 | 0.8 | – | – | – | – | 0.5 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Outline | lines | 2.60 | 9.16 | 0.055 | 2.94 | 105.75 | 5.0 | 14 | 0.71 | 95 | 4 | – | – | – | – | – | – | 0.0 | 0.0 | 0.053 | 0.02 | 2.4 | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.43 | 3.53 | 3.106 | 88.50 | 0.78 | 69.9 | 352 | 38.92 | 38 | 10 | 80 | 237.0 | 0.0 | 0 | 13.8 | – | 59.1 | 0.2 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.48 | 3.47 | 0.090 | 0.82 | 0.78 | 3.9 | 49 | 3.85 | 40 | 10 | 80 | 2.6 | 0.0 | 0 | 10.6 | – | 0.9 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | plain shapes | 3.18 | 9.53 | 0.102 | 1.00 | 1.00 | 6.0 | 40 | 1.32 | 41 | 27 | – | – | – | – | 15.8 | 11.5 | 0.0 | 0.0 | – | – | – | 57 | – | 0.0 | – | – | – |
|
||||
| 6 Colors with Shapes | plain shapes | 0.91 | 6.82 | 0.089 | 0.99 | 0.99 | 6.0 | 43 | 1.43 | 49 | 33 | – | – | 0.0 | 0 | 10.2 | 10.9 | 0.0 | 0.0 | – | – | – | 62 | – | 0.0 | – | – | – |
|
||||
| 6 Colors with Auto Grouping | two touching halves, four-colour badge | 0.55 | 4.99 | 0.140 | 1.34 | 1.19 | 4.0 | 49 | 3.07 | 39 | 16 | 91 | 6.1 | 0.0 | 0 | 0.3 | 0.3 | 0.0 | 0.0 | – | – | – | – | 27 | 0.0 | – | – | – |
|
||||
@@ -110,7 +113,7 @@ Each preset on the inputs it is meant for, all sizes and variants.
|
||||
| High Fidelity Photo, no Gradients | two-stop gradient, three-stop gradient in a disc | 0.79 | 2.98 | – | – | – | 38.1 | 3827 | – | 31 | 30 | – | – | – | – | 0.0 | – | 1.1 | 0.0 | – | – | – | – | – | 0.0 | 0 | – | – |
|
||||
| High Fidelity Photo with Gradients | two-stop gradient, three-stop gradient in a disc | 0.46 | 1.31 | – | – | – | 20.6 | 759 | – | 9 | 5 | – | – | – | – | 0.0 | – | 2.9 | 0.0 | – | – | – | – | – | 1.0 | 86 | 1.5 | 21 |
|
||||
|
||||
The sections below are the numbers now (with levelling bounded).
|
||||
The sections below are the numbers now (with Flat Logo's tones folded).
|
||||
|
||||
## 1. Each preset on the inputs it is meant for
|
||||
|
||||
@@ -132,7 +135,7 @@ All sizes and variants (clean, aliased, blurred, noisy, scanned) of those inputs
|
||||
| Line Art | lines | 2.62 | 9.49 | 0.056 | 0.99 | 105.72 | 5.4 | 43 | 2.14 | 40 | 0 | – | – | – | – | – | – | 0.1 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Technical Drawing | polygons, star, letter A, lines, askew shapes and lines | 2.65 | 10.88 | 0.126 | 1.46 | 105.64 | 4.7 | 38 | 2.55 | 81 | 19 | 78 | 0.8 | – | – | – | – | 0.5 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Outline | lines | 2.60 | 9.16 | 0.055 | 2.94 | 105.75 | 5.0 | 14 | 0.71 | 95 | 4 | – | – | – | – | – | – | 0.0 | 0.0 | 0.053 | 0.02 | 2.4 | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.43 | 3.53 | 3.106 | 88.50 | 0.78 | 69.9 | 352 | 38.92 | 38 | 10 | 80 | 237.0 | 0.0 | 0 | 13.8 | – | 59.1 | 0.2 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.48 | 3.47 | 0.090 | 0.82 | 0.78 | 3.9 | 49 | 3.85 | 40 | 10 | 80 | 2.6 | 0.0 | 0 | 10.6 | – | 0.9 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | plain shapes | 3.18 | 9.53 | 0.102 | 1.00 | 1.00 | 6.0 | 40 | 1.32 | 41 | 27 | – | – | – | – | 15.8 | 11.5 | 0.0 | 0.0 | – | – | – | 57 | – | 0.0 | – | – | – |
|
||||
| 6 Colors with Shapes | plain shapes | 0.91 | 6.82 | 0.089 | 0.99 | 0.99 | 6.0 | 43 | 1.43 | 49 | 33 | – | – | 0.0 | 0 | 10.2 | 10.9 | 0.0 | 0.0 | – | – | – | 62 | – | 0.0 | – | – | – |
|
||||
| 6 Colors with Auto Grouping | two touching halves, four-colour badge | 0.55 | 4.99 | 0.140 | 1.34 | 1.19 | 4.0 | 49 | 3.07 | 39 | 16 | 91 | 6.1 | 0.0 | 0 | 0.3 | 0.3 | 0.0 | 0.0 | – | – | – | – | 27 | 0.0 | – | – | – |
|
||||
@@ -266,7 +269,7 @@ All sizes and variants (clean, aliased, blurred, noisy, scanned) of those inputs
|
||||
| Flat Logo | plain shapes | 0.16 | 3.09 | 0.061 | 0.30 | 0.27 | 6.0 | 70 | 2.32 | 57 | 4 | – | – | 0.0 | 0 | 6.8 | – | 0.0 | 0.0 | – | – | – | 0 | – | 0.0 | – | – | – |
|
||||
| Flat Logo | flat colour art | 0.07 | 1.53 | 0.065 | 0.31 | 0.27 | 3.0 | 36 | 3.51 | 47 | 11 | 100 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | gradient, photo | refused | | | | | | | | | | | | | | | | | | | | | | | | | | |
|
||||
| Flat Logo | linear gradients | 6.77 | 6.85 | – | – | – | 8918.3 | 41974 | – | 7 | 0 | – | – | – | – | 2733.2 | – | 7382.7 | 0.2 | – | – | – | – | – | 0.0 | 0 | – | – |
|
||||
| Flat Logo | linear gradients | 6.49 | 6.47 | – | – | – | 484.0 | 4773 | – | 58 | 0 | – | – | – | – | 146.0 | – | 446.7 | 0.1 | – | – | – | – | – | 0.0 | 0 | – | – |
|
||||
| Default with Shapes | curved shapes | 0.08 | 3.19 | 0.058 | 0.38 | 0.38 | 1.8 | 13 | 1.44 | 14 | 14 | 100 | 0.0 | – | – | 12.2 | 7.4 | 0.0 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | polygons | 0.06 | 2.34 | 0.040 | 0.47 | 1.26 | 2.0 | 14 | 1.26 | 88 | 5 | 95 | 0.0 | – | – | 0.0 | 2.0 | 0.0 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | thin lines | 0.54 | 4.53 | 0.049 | 0.52 | 105.72 | 3.3 | 25 | 1.27 | 40 | 0 | – | – | – | – | 0.0 | 18.3 | 0.0 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
@@ -418,7 +421,7 @@ All sizes and variants (clean, aliased, blurred, noisy, scanned) of those inputs
|
||||
| Flat Logo | aliased | 0.13 | 5.87 | 0.218 | 0.65 | 0.65 | 3.0 | 31 | 1.96 | 44 | 19 | 50 | 0.0 | 0.0 | 0 | 5.8 | – | 0.0 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | blurred | 0.54 | 8.33 | 0.052 | 0.62 | 0.78 | 3.0 | 28 | 1.78 | 43 | 16 | 50 | 0.0 | 0.0 | 0 | 5.8 | – | 0.0 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | noisy | 0.81 | 1.77 | 0.076 | 0.39 | 0.32 | 3.5 | 44 | 2.63 | 31 | 0 | 100 | 1.0 | 0.0 | 0 | 6.9 | – | 0.5 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | scanned | 1.31 | 4.15 | 21.204 | 88.50 | 0.66 | 470.5 | 2251 | 255.53 | 11 | 0 | 62 | 1658.0 | 0.0 | 0 | 58.0 | – | 413.5 | 0.2 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Flat Logo | scanned | 1.68 | 3.76 | 0.088 | 0.82 | 0.66 | 8.5 | 128 | 10.04 | 23 | 0 | 62 | 17.0 | 0.0 | 0 | 35.1 | – | 5.5 | 0.0 | – | – | – | – | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | clean | 0.13 | 2.62 | 0.055 | 0.35 | 0.34 | 6.0 | 38 | 1.28 | 55 | 48 | – | – | – | – | 16.4 | 8.2 | 0.0 | 0.0 | – | – | – | 72 | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | aliased | 0.63 | 16.01 | 0.264 | 1.00 | 0.90 | 6.0 | 31 | 1.03 | 45 | 31 | – | – | – | – | 19.2 | 17.7 | 0.0 | 0.0 | – | – | – | 33 | – | 0.0 | – | – | – |
|
||||
| Default with Shapes | blurred | 1.58 | 15.95 | 0.096 | 0.81 | 1.00 | 6.0 | 51 | 1.70 | 17 | 17 | – | – | – | – | 8.0 | 7.6 | 0.0 | 0.0 | – | – | – | 33 | – | 0.0 | – | – | – |
|
||||
@@ -506,154 +509,155 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
|
||||
| preset | image | megapixels | result |
|
||||
|:--|:--|--:|:--|
|
||||
| Default | photo-like | 1 | 0.04 s, 29 paths, 6278 anchors, 12 MB |
|
||||
| Default | four-colour badge | 1 | 0.02 s, 3 paths, 64 anchors, 12 MB |
|
||||
| Default | four-colour badge | 1 | 0.03 s, 3 paths, 64 anchors, 12 MB |
|
||||
| Auto Color | photo-like | 1 | 0.16 s, 732 paths, 45265 anchors, 23 MB |
|
||||
| Auto Color | four-colour badge | 1 | 0.06 s, 5 paths, 84 anchors, 12 MB |
|
||||
| High Fidelity Photo | photo-like | 1 | 0.25 s, 968 paths, 117041 anchors, 164 MB |
|
||||
| Auto Color | four-colour badge | 1 | 0.05 s, 5 paths, 84 anchors, 12 MB |
|
||||
| High Fidelity Photo | photo-like | 1 | 0.22 s, 968 paths, 117041 anchors, 168 MB |
|
||||
| High Fidelity Photo | four-colour badge | 1 | 0.06 s, 6 paths, 172 anchors, 23 MB |
|
||||
| Low Fidelity Photo | photo-like | 1 | 0.15 s, 129 paths, 23014 anchors, 104 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 1 | 0.07 s, 6 paths, 136 anchors, 23 MB |
|
||||
| 3 Colors | photo-like | 1 | 0.11 s, 9 paths, 3341 anchors, 18 MB |
|
||||
| 3 Colors | four-colour badge | 1 | 0.07 s, 4 paths, 88 anchors, 13 MB |
|
||||
| 6 Colors | photo-like | 1 | 0.10 s, 78 paths, 9076 anchors, 24 MB |
|
||||
| 6 Colors | four-colour badge | 1 | 0.07 s, 5 paths, 84 anchors, 12 MB |
|
||||
| 16 Colors | photo-like | 1 | 0.18 s, 178 paths, 20470 anchors, 32 MB |
|
||||
| 16 Colors | four-colour badge | 1 | 0.07 s, 5 paths, 88 anchors, 12 MB |
|
||||
| Shades of Gray | photo-like | 1 | 0.11 s, 81 paths, 6698 anchors, 40 MB |
|
||||
| Shades of Gray | four-colour badge | 1 | 0.08 s, 10 paths, 725 anchors, 34 MB |
|
||||
| Low Fidelity Photo | photo-like | 1 | 0.14 s, 129 paths, 23014 anchors, 104 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 1 | 0.05 s, 6 paths, 136 anchors, 23 MB |
|
||||
| 3 Colors | photo-like | 1 | 0.09 s, 9 paths, 3341 anchors, 18 MB |
|
||||
| 3 Colors | four-colour badge | 1 | 0.05 s, 4 paths, 88 anchors, 13 MB |
|
||||
| 6 Colors | photo-like | 1 | 0.10 s, 78 paths, 9076 anchors, 23 MB |
|
||||
| 6 Colors | four-colour badge | 1 | 0.05 s, 5 paths, 84 anchors, 12 MB |
|
||||
| 16 Colors | photo-like | 1 | 0.12 s, 178 paths, 20470 anchors, 31 MB |
|
||||
| 16 Colors | four-colour badge | 1 | 0.05 s, 5 paths, 88 anchors, 12 MB |
|
||||
| Shades of Gray | photo-like | 1 | 0.10 s, 81 paths, 6698 anchors, 43 MB |
|
||||
| Shades of Gray | four-colour badge | 1 | 0.07 s, 10 paths, 725 anchors, 34 MB |
|
||||
| Black and White Logo | photo-like | 1 | 0.05 s, 71 paths, 4933 anchors, 8 MB |
|
||||
| Black and White Logo | four-colour badge | 1 | 0.02 s, 1 paths, 47 anchors, 6 MB |
|
||||
| Sketched Art | photo-like | 1 | 0.05 s, 5 paths, 2873 anchors, 8 MB |
|
||||
| Sketched Art | photo-like | 1 | 0.04 s, 5 paths, 2873 anchors, 8 MB |
|
||||
| Sketched Art | four-colour badge | 1 | 0.02 s, 2 paths, 114 anchors, 6 MB |
|
||||
| Silhouettes | photo-like | 1 | 0.04 s, 2 paths, 556 anchors, 9 MB |
|
||||
| Silhouettes | four-colour badge | 1 | 0.02 s, 1 paths, 24 anchors, 6 MB |
|
||||
| Line Art | photo-like | 1 | 0.05 s, 54 paths, 4495 anchors, 8 MB |
|
||||
| Line Art | photo-like | 1 | 0.04 s, 54 paths, 4495 anchors, 8 MB |
|
||||
| Line Art | four-colour badge | 1 | 0.02 s, 1 paths, 38 anchors, 6 MB |
|
||||
| Technical Drawing | photo-like | 1 | 0.05 s, 221 paths, 6258 anchors, 8 MB |
|
||||
| Technical Drawing | photo-like | 1 | 0.04 s, 221 paths, 6258 anchors, 8 MB |
|
||||
| Technical Drawing | four-colour badge | 1 | 0.02 s, 1 paths, 47 anchors, 6 MB |
|
||||
| Outline | photo-like | 1 | 0.06 s, 45 paths, 4110 anchors, 16 MB |
|
||||
| Outline | four-colour badge | 1 | 0.03 s, 1 paths, 38 anchors, 11 MB |
|
||||
| Flat Logo | photo-like | 1 | refused after 0.11 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 1 | 0.80 s, 5 paths, 78 anchors, 146 MB |
|
||||
| Default with Shapes | photo-like | 1 | 0.11 s, 29 paths, 6278 anchors, 12 MB |
|
||||
| Default with Shapes | four-colour badge | 1 | 0.04 s, 3 paths, 63 anchors, 12 MB |
|
||||
| 6 Colors with Shapes | photo-like | 1 | 0.09 s, 78 paths, 9076 anchors, 24 MB |
|
||||
| Flat Logo | photo-like | 1 | refused after 0.15 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 1 | 0.69 s, 5 paths, 78 anchors, 146 MB |
|
||||
| Default with Shapes | photo-like | 1 | 0.05 s, 29 paths, 6278 anchors, 12 MB |
|
||||
| Default with Shapes | four-colour badge | 1 | 0.02 s, 3 paths, 63 anchors, 12 MB |
|
||||
| 6 Colors with Shapes | photo-like | 1 | 0.10 s, 78 paths, 9076 anchors, 24 MB |
|
||||
| 6 Colors with Shapes | four-colour badge | 1 | 0.05 s, 5 paths, 84 anchors, 12 MB |
|
||||
| 6 Colors with Auto Grouping | photo-like | 1 | 0.08 s, 78 paths, 9076 anchors, 24 MB |
|
||||
| 6 Colors with Auto Grouping | four-colour badge | 1 | 0.05 s, 5 paths, 84 anchors, 13 MB |
|
||||
| Shades of Gray with Auto Grouping | photo-like | 1 | 0.14 s, 81 paths, 6698 anchors, 40 MB |
|
||||
| Shades of Gray with Auto Grouping | four-colour badge | 1 | 0.07 s, 10 paths, 725 anchors, 35 MB |
|
||||
| High Fidelity Photo with Auto Grouping | photo-like | 1 | 0.25 s, 968 paths, 117041 anchors, 169 MB |
|
||||
| High Fidelity Photo with Auto Grouping | four-colour badge | 1 | 0.08 s, 6 paths, 172 anchors, 23 MB |
|
||||
| 6 Colors, no Gradients | photo-like | 1 | 0.49 s, 78 paths, 9076 anchors, 24 MB |
|
||||
| 6 Colors with Auto Grouping | photo-like | 1 | 0.11 s, 78 paths, 9076 anchors, 24 MB |
|
||||
| 6 Colors with Auto Grouping | four-colour badge | 1 | 0.06 s, 5 paths, 84 anchors, 12 MB |
|
||||
| Shades of Gray with Auto Grouping | photo-like | 1 | 0.10 s, 81 paths, 6698 anchors, 43 MB |
|
||||
| Shades of Gray with Auto Grouping | four-colour badge | 1 | 0.06 s, 10 paths, 725 anchors, 35 MB |
|
||||
| High Fidelity Photo with Auto Grouping | photo-like | 1 | 0.27 s, 968 paths, 117041 anchors, 170 MB |
|
||||
| High Fidelity Photo with Auto Grouping | four-colour badge | 1 | 0.05 s, 6 paths, 172 anchors, 24 MB |
|
||||
| 6 Colors, no Gradients | photo-like | 1 | 0.09 s, 78 paths, 9076 anchors, 23 MB |
|
||||
| 6 Colors, no Gradients | four-colour badge | 1 | 0.05 s, 5 paths, 84 anchors, 12 MB |
|
||||
| 6 Colors with Gradients | photo-like | 1 | 0.18 s, 78 paths, 9076 anchors, 32 MB |
|
||||
| 6 Colors with Gradients | four-colour badge | 1 | 0.11 s, 5 paths, 84 anchors, 12 MB |
|
||||
| High Fidelity Photo, no Gradients | photo-like | 1 | 0.23 s, 968 paths, 117041 anchors, 163 MB |
|
||||
| 6 Colors with Gradients | photo-like | 1 | 0.13 s, 78 paths, 9076 anchors, 32 MB |
|
||||
| 6 Colors with Gradients | four-colour badge | 1 | 0.08 s, 5 paths, 84 anchors, 12 MB |
|
||||
| High Fidelity Photo, no Gradients | photo-like | 1 | 0.23 s, 968 paths, 117041 anchors, 170 MB |
|
||||
| High Fidelity Photo, no Gradients | four-colour badge | 1 | 0.07 s, 6 paths, 172 anchors, 23 MB |
|
||||
| High Fidelity Photo with Gradients | photo-like | 1 | 0.32 s, 968 paths, 117041 anchors, 168 MB |
|
||||
| High Fidelity Photo with Gradients | four-colour badge | 1 | 0.10 s, 6 paths, 172 anchors, 23 MB |
|
||||
| High Fidelity Photo with Gradients | photo-like | 1 | 0.28 s, 968 paths, 117041 anchors, 169 MB |
|
||||
| High Fidelity Photo with Gradients | four-colour badge | 1 | 0.09 s, 6 paths, 172 anchors, 23 MB |
|
||||
| Default | photo-like | 4 | 0.21 s, 48 paths, 14438 anchors, 49 MB |
|
||||
| Default | four-colour badge | 4 | 0.11 s, 3 paths, 72 anchors, 48 MB |
|
||||
| Auto Color | photo-like | 4 | 0.44 s, 1469 paths, 149389 anchors, 115 MB |
|
||||
| Auto Color | four-colour badge | 4 | 0.21 s, 5 paths, 92 anchors, 46 MB |
|
||||
| High Fidelity Photo | photo-like | 4 | 0.78 s, 4479 paths, 337884 anchors, 623 MB |
|
||||
| High Fidelity Photo | four-colour badge | 4 | 0.29 s, 6 paths, 200 anchors, 97 MB |
|
||||
| Low Fidelity Photo | photo-like | 4 | 0.41 s, 299 paths, 61601 anchors, 406 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 4 | 0.24 s, 6 paths, 152 anchors, 97 MB |
|
||||
| 3 Colors | photo-like | 4 | 0.31 s, 20 paths, 8473 anchors, 73 MB |
|
||||
| 3 Colors | four-colour badge | 4 | 0.24 s, 4 paths, 122 anchors, 53 MB |
|
||||
| 6 Colors | photo-like | 4 | 0.34 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| Default | four-colour badge | 4 | 0.12 s, 3 paths, 72 anchors, 48 MB |
|
||||
| Auto Color | photo-like | 4 | 0.46 s, 1469 paths, 149389 anchors, 115 MB |
|
||||
| Auto Color | four-colour badge | 4 | 0.22 s, 5 paths, 92 anchors, 47 MB |
|
||||
| High Fidelity Photo | photo-like | 4 | 0.75 s, 4479 paths, 337884 anchors, 634 MB |
|
||||
| High Fidelity Photo | four-colour badge | 4 | 0.25 s, 6 paths, 200 anchors, 97 MB |
|
||||
| Low Fidelity Photo | photo-like | 4 | 0.41 s, 299 paths, 61601 anchors, 402 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 4 | 0.25 s, 6 paths, 152 anchors, 96 MB |
|
||||
| 3 Colors | photo-like | 4 | 0.29 s, 20 paths, 8473 anchors, 73 MB |
|
||||
| 3 Colors | four-colour badge | 4 | 0.19 s, 4 paths, 122 anchors, 51 MB |
|
||||
| 6 Colors | photo-like | 4 | 0.30 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors | four-colour badge | 4 | 0.22 s, 5 paths, 92 anchors, 46 MB |
|
||||
| 16 Colors | photo-like | 4 | 0.37 s, 353 paths, 52770 anchors, 130 MB |
|
||||
| 16 Colors | four-colour badge | 4 | 0.22 s, 5 paths, 96 anchors, 46 MB |
|
||||
| Shades of Gray | photo-like | 4 | 0.50 s, 137 paths, 18823 anchors, 162 MB |
|
||||
| Shades of Gray | four-colour badge | 4 | 0.33 s, 10 paths, 1256 anchors, 137 MB |
|
||||
| Black and White Logo | photo-like | 4 | 0.25 s, 208 paths, 13546 anchors, 32 MB |
|
||||
| Black and White Logo | four-colour badge | 4 | 0.11 s, 1 paths, 57 anchors, 24 MB |
|
||||
| Sketched Art | photo-like | 4 | 0.24 s, 9 paths, 5387 anchors, 32 MB |
|
||||
| Sketched Art | four-colour badge | 4 | 0.11 s, 2 paths, 196 anchors, 24 MB |
|
||||
| Silhouettes | photo-like | 4 | 0.24 s, 2 paths, 1465 anchors, 36 MB |
|
||||
| 16 Colors | photo-like | 4 | 0.34 s, 353 paths, 52770 anchors, 129 MB |
|
||||
| 16 Colors | four-colour badge | 4 | 0.24 s, 5 paths, 96 anchors, 46 MB |
|
||||
| Shades of Gray | photo-like | 4 | 0.38 s, 137 paths, 18823 anchors, 160 MB |
|
||||
| Shades of Gray | four-colour badge | 4 | 0.28 s, 10 paths, 1256 anchors, 137 MB |
|
||||
| Black and White Logo | photo-like | 4 | 0.21 s, 208 paths, 13546 anchors, 32 MB |
|
||||
| Black and White Logo | four-colour badge | 4 | 0.09 s, 1 paths, 57 anchors, 24 MB |
|
||||
| Sketched Art | photo-like | 4 | 0.20 s, 9 paths, 5387 anchors, 32 MB |
|
||||
| Sketched Art | four-colour badge | 4 | 0.09 s, 2 paths, 196 anchors, 24 MB |
|
||||
| Silhouettes | photo-like | 4 | 0.22 s, 2 paths, 1465 anchors, 36 MB |
|
||||
| Silhouettes | four-colour badge | 4 | 0.09 s, 1 paths, 28 anchors, 24 MB |
|
||||
| Line Art | photo-like | 4 | 0.22 s, 127 paths, 11330 anchors, 32 MB |
|
||||
| Line Art | photo-like | 4 | 0.21 s, 127 paths, 11330 anchors, 32 MB |
|
||||
| Line Art | four-colour badge | 4 | 0.09 s, 1 paths, 42 anchors, 24 MB |
|
||||
| Technical Drawing | photo-like | 4 | 0.21 s, 931 paths, 20628 anchors, 32 MB |
|
||||
| Technical Drawing | four-colour badge | 4 | 0.08 s, 1 paths, 57 anchors, 24 MB |
|
||||
| Outline | photo-like | 4 | 0.25 s, 113 paths, 10815 anchors, 64 MB |
|
||||
| Outline | four-colour badge | 4 | 0.13 s, 1 paths, 42 anchors, 44 MB |
|
||||
| Flat Logo | photo-like | 4 | refused after 0.10 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 4 | 0.67 s, 5 paths, 69 anchors, 146 MB |
|
||||
| Default with Shapes | photo-like | 4 | 0.20 s, 48 paths, 14438 anchors, 49 MB |
|
||||
| Technical Drawing | four-colour badge | 4 | 0.09 s, 1 paths, 57 anchors, 24 MB |
|
||||
| Outline | photo-like | 4 | 0.28 s, 113 paths, 10815 anchors, 64 MB |
|
||||
| Outline | four-colour badge | 4 | 0.14 s, 1 paths, 42 anchors, 44 MB |
|
||||
| Flat Logo | photo-like | 4 | refused after 0.14 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 4 | 0.68 s, 5 paths, 69 anchors, 146 MB |
|
||||
| Default with Shapes | photo-like | 4 | 0.21 s, 48 paths, 14438 anchors, 49 MB |
|
||||
| Default with Shapes | four-colour badge | 4 | 0.10 s, 3 paths, 72 anchors, 48 MB |
|
||||
| 6 Colors with Shapes | photo-like | 4 | 0.31 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors with Shapes | four-colour badge | 4 | 0.21 s, 5 paths, 92 anchors, 46 MB |
|
||||
| 6 Colors with Auto Grouping | photo-like | 4 | 0.40 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors with Auto Grouping | four-colour badge | 4 | 0.22 s, 5 paths, 92 anchors, 46 MB |
|
||||
| Shades of Gray with Auto Grouping | photo-like | 4 | 0.38 s, 137 paths, 18823 anchors, 165 MB |
|
||||
| Shades of Gray with Auto Grouping | four-colour badge | 4 | 0.29 s, 10 paths, 1256 anchors, 138 MB |
|
||||
| High Fidelity Photo with Auto Grouping | photo-like | 4 | 1.06 s, 4479 paths, 337884 anchors, 622 MB |
|
||||
| 6 Colors with Shapes | photo-like | 4 | 0.28 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors with Shapes | four-colour badge | 4 | 0.19 s, 5 paths, 92 anchors, 46 MB |
|
||||
| 6 Colors with Auto Grouping | photo-like | 4 | 0.29 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors with Auto Grouping | four-colour badge | 4 | 0.19 s, 5 paths, 92 anchors, 46 MB |
|
||||
| Shades of Gray with Auto Grouping | photo-like | 4 | 0.31 s, 137 paths, 18823 anchors, 162 MB |
|
||||
| Shades of Gray with Auto Grouping | four-colour badge | 4 | 0.28 s, 10 paths, 1256 anchors, 137 MB |
|
||||
| High Fidelity Photo with Auto Grouping | photo-like | 4 | 0.93 s, 4479 paths, 337884 anchors, 612 MB |
|
||||
| High Fidelity Photo with Auto Grouping | four-colour badge | 4 | 0.28 s, 6 paths, 200 anchors, 97 MB |
|
||||
| 6 Colors, no Gradients | photo-like | 4 | 0.36 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors, no Gradients | four-colour badge | 4 | 0.26 s, 5 paths, 92 anchors, 46 MB |
|
||||
| 6 Colors with Gradients | photo-like | 4 | 0.73 s, 115 paths, 23295 anchors, 128 MB |
|
||||
| 6 Colors with Gradients | four-colour badge | 4 | 0.40 s, 5 paths, 92 anchors, 46 MB |
|
||||
| High Fidelity Photo, no Gradients | photo-like | 4 | 0.98 s, 4479 paths, 337884 anchors, 626 MB |
|
||||
| High Fidelity Photo, no Gradients | four-colour badge | 4 | 0.29 s, 6 paths, 200 anchors, 97 MB |
|
||||
| High Fidelity Photo with Gradients | photo-like | 4 | 1.22 s, 4479 paths, 337884 anchors, 629 MB |
|
||||
| High Fidelity Photo with Gradients | four-colour badge | 4 | 0.43 s, 6 paths, 200 anchors, 97 MB |
|
||||
| Default | photo-like | 16 | 1.24 s, 127 paths, 34034 anchors, 199 MB |
|
||||
| Default | four-colour badge | 16 | 1.54 s, 3 paths, 88 anchors, 184 MB |
|
||||
| Auto Color | photo-like | 16 | 2.44 s, 5390 paths, 454670 anchors, 272 MB |
|
||||
| Auto Color | four-colour badge | 16 | 1.08 s, 5 paths, 112 anchors, 184 MB |
|
||||
| High Fidelity Photo | photo-like | 16 | 4.70 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo | four-colour badge | 16 | 1.27 s, 6 paths, 264 anchors, 257 MB |
|
||||
| Low Fidelity Photo | photo-like | 16 | 2.21 s, 2036 paths, 182778 anchors, 754 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 16 | 1.25 s, 6 paths, 180 anchors, 257 MB |
|
||||
| 3 Colors | photo-like | 16 | 1.38 s, 76 paths, 23024 anchors, 290 MB |
|
||||
| 3 Colors | four-colour badge | 16 | 0.93 s, 4 paths, 190 anchors, 205 MB |
|
||||
| 6 Colors | photo-like | 16 | 1.38 s, 470 paths, 60831 anchors, 298 MB |
|
||||
| 6 Colors | four-colour badge | 16 | 0.91 s, 5 paths, 112 anchors, 184 MB |
|
||||
| 16 Colors | photo-like | 16 | 1.40 s, 1973 paths, 145642 anchors, 304 MB |
|
||||
| 16 Colors | four-colour badge | 16 | 0.99 s, 5 paths, 114 anchors, 184 MB |
|
||||
| Shades of Gray | photo-like | 16 | 1.94 s, 686 paths, 53654 anchors, 407 MB |
|
||||
| Shades of Gray | four-colour badge | 16 | 1.59 s, 9 paths, 2208 anchors, 270 MB |
|
||||
| Black and White Logo | photo-like | 16 | 1.32 s, 1128 paths, 45273 anchors, 129 MB |
|
||||
| Black and White Logo | four-colour badge | 16 | 0.54 s, 1 paths, 69 anchors, 88 MB |
|
||||
| Sketched Art | photo-like | 16 | 1.30 s, 15 paths, 11486 anchors, 128 MB |
|
||||
| Sketched Art | four-colour badge | 16 | 0.56 s, 2 paths, 361 anchors, 88 MB |
|
||||
| Silhouettes | photo-like | 16 | 1.94 s, 3 paths, 3350 anchors, 144 MB |
|
||||
| Silhouettes | four-colour badge | 16 | 0.61 s, 1 paths, 32 anchors, 95 MB |
|
||||
| Line Art | photo-like | 16 | 1.55 s, 713 paths, 37370 anchors, 129 MB |
|
||||
| Line Art | four-colour badge | 16 | 0.60 s, 1 paths, 49 anchors, 88 MB |
|
||||
| Technical Drawing | photo-like | 16 | 1.78 s, 4577 paths, 76559 anchors, 130 MB |
|
||||
| Technical Drawing | four-colour badge | 16 | 0.59 s, 1 paths, 69 anchors, 88 MB |
|
||||
| Outline | photo-like | 16 | 1.83 s, 632 paths, 34884 anchors, 256 MB |
|
||||
| Outline | four-colour badge | 16 | 0.91 s, 1 paths, 49 anchors, 176 MB |
|
||||
| Flat Logo | photo-like | 16 | refused after 0.20 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 16 | 1.17 s, 5 paths, 67 anchors, 146 MB |
|
||||
| Default with Shapes | photo-like | 16 | 1.89 s, 127 paths, 34034 anchors, 199 MB |
|
||||
| Default with Shapes | four-colour badge | 16 | 1.81 s, 3 paths, 88 anchors, 184 MB |
|
||||
| 6 Colors with Shapes | photo-like | 16 | 3.30 s, 470 paths, 60831 anchors, 298 MB |
|
||||
| 6 Colors with Shapes | four-colour badge | 16 | 1.73 s, 5 paths, 112 anchors, 185 MB |
|
||||
| 6 Colors with Auto Grouping | photo-like | 16 | 2.14 s, 470 paths, 60831 anchors, 299 MB |
|
||||
| 6 Colors with Auto Grouping | four-colour badge | 16 | 1.50 s, 5 paths, 112 anchors, 185 MB |
|
||||
| Shades of Gray with Auto Grouping | photo-like | 16 | 2.71 s, 686 paths, 53654 anchors, 407 MB |
|
||||
| Shades of Gray with Auto Grouping | four-colour badge | 16 | 1.85 s, 9 paths, 2208 anchors, 270 MB |
|
||||
| High Fidelity Photo with Auto Grouping | photo-like | 16 | 10.82 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo with Auto Grouping | four-colour badge | 16 | 1.69 s, 6 paths, 264 anchors, 257 MB |
|
||||
| 6 Colors, no Gradients | photo-like | 16 | 1.81 s, 470 paths, 60831 anchors, 299 MB |
|
||||
| 6 Colors, no Gradients | four-colour badge | 16 | 1.26 s, 5 paths, 112 anchors, 185 MB |
|
||||
| 6 Colors with Gradients | photo-like | 16 | 4.20 s, 470 paths, 60831 anchors, 512 MB |
|
||||
| 6 Colors with Gradients | four-colour badge | 16 | 1.76 s, 5 paths, 112 anchors, 185 MB |
|
||||
| High Fidelity Photo, no Gradients | photo-like | 16 | 5.32 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo, no Gradients | four-colour badge | 16 | 1.66 s, 6 paths, 264 anchors, 257 MB |
|
||||
| High Fidelity Photo with Gradients | photo-like | 16 | 7.38 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo with Gradients | four-colour badge | 16 | 1.79 s, 6 paths, 264 anchors, 263 MB |
|
||||
| 6 Colors, no Gradients | photo-like | 4 | 0.31 s, 115 paths, 23295 anchors, 100 MB |
|
||||
| 6 Colors, no Gradients | four-colour badge | 4 | 0.21 s, 5 paths, 92 anchors, 47 MB |
|
||||
| 6 Colors with Gradients | photo-like | 4 | 0.65 s, 115 paths, 23295 anchors, 128 MB |
|
||||
| 6 Colors with Gradients | four-colour badge | 4 | 0.30 s, 5 paths, 92 anchors, 46 MB |
|
||||
| High Fidelity Photo, no Gradients | photo-like | 4 | 0.72 s, 4479 paths, 337884 anchors, 618 MB |
|
||||
| High Fidelity Photo, no Gradients | four-colour badge | 4 | 0.24 s, 6 paths, 200 anchors, 94 MB |
|
||||
| High Fidelity Photo with Gradients | photo-like | 4 | 1.07 s, 4479 paths, 337884 anchors, 612 MB |
|
||||
| High Fidelity Photo with Gradients | four-colour badge | 4 | 0.34 s, 6 paths, 200 anchors, 97 MB |
|
||||
| Default | photo-like | 16 | 1.13 s, 127 paths, 34034 anchors, 199 MB |
|
||||
| Default | four-colour badge | 16 | 0.56 s, 3 paths, 88 anchors, 184 MB |
|
||||
| Auto Color | photo-like | 16 | 1.86 s, 5390 paths, 454670 anchors, 274 MB |
|
||||
| Auto Color | four-colour badge | 16 | 0.91 s, 5 paths, 112 anchors, 184 MB |
|
||||
| High Fidelity Photo | photo-like | 16 | 4.54 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo | four-colour badge | 16 | 1.61 s, 6 paths, 264 anchors, 263 MB |
|
||||
| Low Fidelity Photo | photo-like | 16 | 2.14 s, 2036 paths, 182778 anchors, 754 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 16 | 1.18 s, 6 paths, 180 anchors, 257 MB |
|
||||
| 3 Colors | photo-like | 16 | 1.92 s, 76 paths, 23024 anchors, 289 MB |
|
||||
| 3 Colors | four-colour badge | 16 | 1.46 s, 4 paths, 190 anchors, 213 MB |
|
||||
| 6 Colors | photo-like | 16 | 2.24 s, 470 paths, 60831 anchors, 298 MB |
|
||||
| 6 Colors | four-colour badge | 16 | 1.19 s, 5 paths, 112 anchors, 184 MB |
|
||||
| 16 Colors | photo-like | 16 | 1.24 s, 1973 paths, 145642 anchors, 297 MB |
|
||||
| 16 Colors | four-colour badge | 16 | 1.42 s, 5 paths, 114 anchors, 184 MB |
|
||||
| Shades of Gray | photo-like | 16 | 1.85 s, 686 paths, 53654 anchors, 407 MB |
|
||||
| Shades of Gray | four-colour badge | 16 | 1.51 s, 9 paths, 2208 anchors, 270 MB |
|
||||
| Black and White Logo | photo-like | 16 | 1.31 s, 1128 paths, 45273 anchors, 129 MB |
|
||||
| Black and White Logo | four-colour badge | 16 | 0.56 s, 1 paths, 69 anchors, 88 MB |
|
||||
| Sketched Art | photo-like | 16 | 1.40 s, 15 paths, 11486 anchors, 128 MB |
|
||||
| Sketched Art | four-colour badge | 16 | 0.57 s, 2 paths, 361 anchors, 88 MB |
|
||||
| Silhouettes | photo-like | 16 | 1.80 s, 3 paths, 3350 anchors, 144 MB |
|
||||
| Silhouettes | four-colour badge | 16 | 0.53 s, 1 paths, 32 anchors, 95 MB |
|
||||
| Line Art | photo-like | 16 | 1.26 s, 713 paths, 37370 anchors, 129 MB |
|
||||
| Line Art | four-colour badge | 16 | 0.49 s, 1 paths, 49 anchors, 88 MB |
|
||||
| Technical Drawing | photo-like | 16 | 2.03 s, 4577 paths, 76559 anchors, 130 MB |
|
||||
| Technical Drawing | four-colour badge | 16 | 0.97 s, 1 paths, 69 anchors, 88 MB |
|
||||
| Outline | photo-like | 16 | 2.07 s, 632 paths, 34884 anchors, 256 MB |
|
||||
| Outline | four-colour badge | 16 | 0.92 s, 1 paths, 49 anchors, 176 MB |
|
||||
| Flat Logo | photo-like | 16 | refused after 0.23 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 16 | 1.02 s, 5 paths, 67 anchors, 146 MB |
|
||||
| Default with Shapes | photo-like | 16 | 1.21 s, 127 paths, 34034 anchors, 199 MB |
|
||||
| Default with Shapes | four-colour badge | 16 | 0.59 s, 3 paths, 88 anchors, 184 MB |
|
||||
| 6 Colors with Shapes | photo-like | 16 | 1.53 s, 470 paths, 60831 anchors, 298 MB |
|
||||
| 6 Colors with Shapes | four-colour badge | 16 | 1.10 s, 5 paths, 112 anchors, 185 MB |
|
||||
| 6 Colors with Auto Grouping | photo-like | 16 | 1.46 s, 470 paths, 60831 anchors, 298 MB |
|
||||
| 6 Colors with Auto Grouping | four-colour badge | 16 | 1.04 s, 5 paths, 112 anchors, 184 MB |
|
||||
| Shades of Gray with Auto Grouping | photo-like | 16 | 1.93 s, 686 paths, 53654 anchors, 407 MB |
|
||||
| Shades of Gray with Auto Grouping | four-colour badge | 16 | 2.38 s, 9 paths, 2208 anchors, 263 MB |
|
||||
| High Fidelity Photo with Auto Grouping | photo-like | 16 | 9.50 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo with Auto Grouping | four-colour badge | 16 | 1.19 s, 6 paths, 264 anchors, 257 MB |
|
||||
| 6 Colors, no Gradients | photo-like | 16 | 1.43 s, 470 paths, 60831 anchors, 299 MB |
|
||||
| 6 Colors, no Gradients | four-colour badge | 16 | 1.04 s, 5 paths, 112 anchors, 185 MB |
|
||||
| 6 Colors with Gradients | photo-like | 16 | 3.19 s, 470 paths, 60831 anchors, 512 MB |
|
||||
| 6 Colors with Gradients | four-colour badge | 16 | 1.59 s, 5 paths, 112 anchors, 185 MB |
|
||||
| High Fidelity Photo, no Gradients | photo-like | 16 | 4.57 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo, no Gradients | four-colour badge | 16 | 1.15 s, 6 paths, 264 anchors, 257 MB |
|
||||
| High Fidelity Photo with Gradients | photo-like | 16 | 6.21 s, 31139 paths, 1192715 anchors, 880 MB |
|
||||
| High Fidelity Photo with Gradients | four-colour badge | 16 | 2.82 s, 6 paths, 264 anchors, 257 MB |
|
||||
|
||||
## Revision history
|
||||
|
||||
| date | change |
|
||||
|:--|:--|
|
||||
| 2026-10-13 | Flat Logo: the tones a scan's uneven paper makes of one colour are one colour (scans: 470 paths and 21 px off to 8.5 paths and 0.09 px) |
|
||||
| 2026-10-13 | Snap Curves To Lines moves no end of a line by more than 1.6 px: long lines a degree or two off stay as the image has them (Technical Drawing: largest distance 4.21 px to 1.46) |
|
||||
| 2026-10-13 | Gradients (off unless asked for): areas that are a linear gradient are found before the palette is made and traced as one path with a gradient fill; linear-gradient inputs with known lines and stops, and four columns for them |
|
||||
| 2026-10-13 | Auto Grouping (off unless asked for): each path that has others inside it is grouped with them; a "grouped %" column, and three presets measured with the option on |
|
||||
|
||||
+72
-1
@@ -65,7 +65,8 @@ the suite) keep the agent to the folders you give it:
|
||||
|
||||
- Every file a command reads must lie inside the **read root**: `open_file`, `file.place`, relinking and
|
||||
updating links, the images an SVG links to, swatch library, graphic style library and colour-profile loads, plug-ins,
|
||||
`file.newFromTemplate`, preset imports, the folders `text.findFontFiles` and `links.findFiles` search.
|
||||
`file.newFromTemplate`, preset imports, the folders `text.findFontFiles` and `links.findFiles` search, the
|
||||
scripts `script.check` reads and the files they include.
|
||||
- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and
|
||||
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
|
||||
library and preset saves to a path, the copies `links.relink {copy}` makes.
|
||||
@@ -704,6 +705,76 @@ reference that names no value fails the step, so a command never runs with one l
|
||||
vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd text.create --params '{"x":10,"y":60,"text":"plain and bold words","size":30}' --cmd text.setRangeStyle --params '{"id":"$1.id","start":10,"end":14,"style":"Bold"}'
|
||||
```
|
||||
|
||||
## Checking scripts written for the reference app
|
||||
|
||||
`script.check {path? | data? | dataBase64?, name?, includes?}` reads a script written for the reference app
|
||||
(`.jsx`, `.jsxinc`, `.js`, or a compiled `.jsxbin`) and reports what it uses. The check does not run any of the
|
||||
script, and it does not change the document or the preferences. The reply's `kind` is `script`, `compiled` (a
|
||||
`.jsxbin` file, or text that starts with `@JSXBIN@`, which the check doesn't read), `module` (a file with a
|
||||
top-level `import` or `export`, such as a React `.jsx` file), `notText` (a NUL byte, or more than one byte in ten a
|
||||
control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, the reply holds these lists:
|
||||
|
||||
- `needs` lists what the script needs from the app that runs it: `language` (the script language's own objects
|
||||
and directives, such as `$`, `alert`, `UnitValue` and `#target`), `dom` (the reference app's document model),
|
||||
`files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has: palettes,
|
||||
`#targetengine`, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading,
|
||||
reflection, actions and `selectTool`).
|
||||
- `directives` lists the `#include`, `#includepath`, `#script`, `#strict`, `#target` and `#targetengine` lines,
|
||||
and `targets` splits each `#target` line's application specifier into `app`, `instance`, `version` and
|
||||
`locale`. A directive is read where it starts a line, written `#name` or `//@name`.
|
||||
- `features` lists the platform features the script uses by id (such as `file.write`, `scriptui.dialog`,
|
||||
`menuCommand` or `e4x`), each with a count and its lines.
|
||||
- `dom.members` lists the document-model member chains as the script writes them
|
||||
(`app.activeDocument.layers[].name`, with `[]` for an index and `()` for a call); a variable assigned from a chain
|
||||
is replaced by that chain, and a name written with `\u` escapes is read as the name it spells. `dom.types` lists
|
||||
the type names the script compares with `.typename` or creates with `new`, apart from the language's, the platform's
|
||||
and the script's own functions.
|
||||
- `menuCommands.strings` lists the string literals the script passes to `executeMenuCommand`
|
||||
(`app.executeMenuCommand('…')`), each with a count and its lines, and `menuCommands.dynamic` counts the calls
|
||||
whose argument isn't a string literal.
|
||||
- `outside` lists what the script does outside the document: `filesWritten` (with the open modes used),
|
||||
`filesOpened` (files opened in other apps), `network`, `nativeCode`, `otherApps` (messages to other apps),
|
||||
`environment` (environment variables), `unseenCode` (`eval`, `new Function` and `$.evalFile`) and
|
||||
`preferences` (the reference app's preferences changed).
|
||||
- `problems` lists what would stop the script loading as written (strings, comments, regular expressions,
|
||||
template literals and XML literals that aren't closed, brackets that don't match, syntax newer than ES3 and
|
||||
unknown directives) and notes on its text (bytes that aren't valid UTF-8, mixed line endings, and nesting deeper
|
||||
than the check follows: more than 4,096 open brackets, or XML literals and the code in their braces nested more
|
||||
than 4,096 levels deep).
|
||||
|
||||
Each entry of `targets`, `directives`, `includes` and `problems` has the `line` it is on (a problem with the whole
|
||||
text has none) and, when it is in an included file, that file's path as `file`. `features`, `menuCommands.strings`
|
||||
and `outside` count the script and its included files together and list up to 20 of the script's own lines.
|
||||
|
||||
With a path and `includes: true`, the check also reads the files the `#include` lines name, and the files their own
|
||||
`#include` lines name. When the script or one of these files assigns a member chain or a File object to a variable,
|
||||
the check treats the variable as holding it in every file. The check looks for a name in the folder of the file that
|
||||
holds the `#include` line, then in each
|
||||
folder that the last `#includepath` line before it names, taken from that same folder. It reads only files inside
|
||||
the script's folder: it doesn't follow an absolute name, a name, folder or link that leads out of the script's
|
||||
folder, a name or folder longer than 4,096 bytes, a file outside the automation read root, or the `JSINCLUDE`
|
||||
environment variable, and it reads each file once. An include's `status` is `checked`, `missing`, `notFollowed`,
|
||||
`cycle` (the file it names includes the file the line is in, directly or through other files), `notRead` (includes
|
||||
weren't read: no `includes`, no path, or the web app), `limit` (one of the limits below was reached) or `unreadable`
|
||||
(a compiled file, or not text), and its `path` is the file found, relative to the script's folder. The check reads
|
||||
at most 8 MiB of a file and 32 MiB in all, follows 64 included files to a depth of 16 and looks up at most 1,024
|
||||
paths for them, keeps 1,000 entries in each list, follows at most 1,000 variables that hold member chains and 1,000
|
||||
that hold File objects (`truncated` is true when an entry or a variable was left out), and cuts the text it echoes
|
||||
from the script to 120 characters.
|
||||
|
||||
File › Scripts › Check Script… picks a `.jsx`, `.jsxinc` or `.jsxbin` file and shows its report, and File › Open and
|
||||
drag-and-drop show the report for those files too; the web app checks the script without its included files. Over
|
||||
the control channel, `ui.checkScript {path? | data? | dataBase64?, name?}` does the same, and with a path it checks
|
||||
the included files too.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"script.check","params":{"path":"/Users/me/Scripts/Tidy Layers.jsx","includes":true}}}
|
||||
```
|
||||
|
||||
```sh
|
||||
vectorcraft-cli run --cmd script.check --params '{"path": "Tidy Layers.jsx"}'
|
||||
```
|
||||
|
||||
## Transparency and opacity masks
|
||||
|
||||
Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
@@ -60,7 +60,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
@@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | M12 row: Image Trace has 15 presets and the Ignore Color, Shapes, Auto Grouping and Gradients options (#1574, #1609, #1618, #1627); its speed figure, measured before these, is dropped for the measurements in `image-trace-quality.md` |
|
||||
| 2026-10-13 | minor | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498); Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536); the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); estimates unchanged |
|
||||
| 2026-10-13 | minor | Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508); commands and MCP `add_text` report area type that does not fit its frame (#1471), a tab stop in the Tabs panel drags from where the press landed (#1458); M9: 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517), `vectorcraft-cli perf` reports load and memory on Windows (#1502); sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), French catalog complete again (#1511), a Hungarian message in the informal address (#1474); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged |
|
||||
| 2026-10-13 | minor | M6: Split Into Grid has its documented options (#1462), the Pathfinder panel works on a group selected alone (#1450); M2: Transform Objects in the Transform dialogs and Transform Each, Reflect across an angle (#1448, #1460); M4: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434), SVG knockout groups without masks (#1435); M7: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); M13: commands refuse a parameter key they don't take (#1442); M5: edits and Alt-drags of thousands of objects (#1429 in part); M14: a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part), a window surface past 8192 px no longer closes the app (#1459 in part); estimates unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user scoreLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: docLine truncated
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -386,6 +386,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Actions panel that records and plays back commands.
|
||||
- File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
@@ -468,7 +469,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
|
||||
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
|
||||
- **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Still weak, by the same numbers: abutting paths leave 22 to 50 px of gaps (each region's edge is still fitted on its own), thin lines in blurred and scanned images take several times the art's anchors, hairlines under half a pixel are lost to the threshold, a scan's uneven paper splits into tones when more colours are asked for than the art has, and a 16 megapixel photo takes about 4 GB while it is traced.
|
||||
- **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, Flat Logo shatters scans, stroke junctions stay at their skeleton pixels, blurred, noisy and scanned gradients are found in part or in pieces, and radial gradients stay bands.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
@@ -487,6 +488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
|
||||
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s.
|
||||
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
|
||||
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
|
||||
@@ -517,6 +519,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).
|
||||
|
||||
- **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames.
|
||||
|
||||
- **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.
|
||||
|
||||
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.
|
||||
@@ -528,6 +532,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Image Trace, measured in `image-trace-quality.md`: shared edges fitted once (#1535), 16 megapixel photos in 0.9 GB (#1546), thin lines (#1563), Ignore Color and two presets (#1574), upright snapping (#1593), stroked centre lines (#1595), corners and Shapes (#1609), Auto Grouping (#1618), Gradients (#1627). Score and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Files: Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Rendering: Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). Localization: The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498) (4,510 rows); scores and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Tools and UI: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Correction: the combined Average and Join with its corner or smooth choice was listed as done in [ui-parity.md](ui-parity.md) and does not exist in the app (`path.join` and `path.average` are separate commands, and Join has no such choice); it is now listed as missing there and in [gap 1](gaps.md); scores and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | UI and tools: sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483); in Outline view a template layer stays in Preview, dimmed (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1500, fixes #1471); graphs: `graph.setData`'s CSV keeps each value exactly (#1485); scores and hours unchanged (estimated) |
|
||||
|
||||
@@ -62,6 +62,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("metafile", Class::Layer(3)),
|
||||
("plugins", Class::Layer(2)),
|
||||
("affinity", Class::Standalone),
|
||||
("scriptcheck", Class::Standalone),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
|
||||
Reference in new issue
Block a user