Compare commits
72
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 | ||
|
|
7af5790b71 | ||
|
|
8a91be7983 | ||
|
|
5bfe774420 | ||
|
|
e918615347 | ||
|
|
250da4226e | ||
|
|
17b4396a97 | ||
|
|
5adeada26d | ||
|
|
175ad4a1e3 | ||
|
|
95304ad7f6 | ||
|
|
02da9bbdc9 | ||
|
|
331fc48b24 | ||
|
|
8d2d838884 | ||
|
|
bc5bdf47d1 | ||
|
|
6b99150aac | ||
|
|
26bb452ba1 | ||
|
|
dbb8bdcaaa | ||
|
|
ecf4ae7a3a | ||
|
|
e518974913 | ||
|
|
a20b0308b8 | ||
|
|
3126e82ebd | ||
|
|
d8a298a310 | ||
|
|
bc343414d8 | ||
|
|
7b0164e4e7 | ||
|
|
670946ef75 | ||
|
|
bdb9963c73 | ||
|
|
dc64aaa41b | ||
|
|
5366cf689e | ||
|
|
cca3a63e4a | ||
|
|
507c6e0e63 | ||
|
|
dfc1b846cc | ||
|
|
a169693817 | ||
|
|
db85d465c8 | ||
|
|
de8d5c96ab | ||
|
|
d44615753a | ||
|
|
e794c1ac63 |
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"] }
|
||||
|
||||
+3
-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 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,8 @@ 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). |
|
||||
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
|
||||
|
||||
@@ -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
|
||||
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
|
||||
|
||||
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
|
||||
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
|
||||
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
|
||||
if let Some(date) = bytes.get_mut(24..36) {
|
||||
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
|
||||
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
|
||||
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
|
||||
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
|
||||
fn aligned(icc: Vec<u8>) -> Vec<u8> {
|
||||
const TABLE: usize = 128;
|
||||
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
|
||||
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
|
||||
let data_start = TABLE + 4 + 12 * n;
|
||||
let mut entries = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let e = TABLE + 4 + 12 * i;
|
||||
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
|
||||
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
|
||||
return icc;
|
||||
}
|
||||
entries.push((off, size));
|
||||
}
|
||||
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
|
||||
return icc;
|
||||
}
|
||||
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| entries[i].0);
|
||||
for i in order {
|
||||
let (off, size) = entries[i];
|
||||
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
|
||||
Some((_, at)) => *at,
|
||||
None => {
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let at = out.len();
|
||||
out.extend_from_slice(&icc[off..off + size]);
|
||||
moved.push(((off, size), at));
|
||||
at
|
||||
}
|
||||
};
|
||||
let e = TABLE + 4 + 12 * i + 4;
|
||||
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
|
||||
}
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let len = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
|
||||
if let Some(id) = out.get_mut(84..100) {
|
||||
id.fill(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text_tag(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
}
|
||||
|
||||
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
|
||||
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
|
||||
assert_eq!(abs(true), abs(false));
|
||||
}
|
||||
|
||||
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
|
||||
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
|
||||
#[test]
|
||||
fn written_profiles_align_their_tags() {
|
||||
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
|
||||
let icc = super::icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
assert_eq!(be(0), icc.len(), "{name}: header size");
|
||||
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
|
||||
let n = be(128);
|
||||
for i in 0..n {
|
||||
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
|
||||
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
|
||||
assert!(off + size <= icc.len());
|
||||
}
|
||||
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
|
||||
let orig = moxcms::ColorProfile::new_srgb();
|
||||
if name == super::SRGB {
|
||||
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
-16
@@ -1,5 +1,7 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
@@ -83,6 +85,77 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||||
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
|
||||
// while a group (including a clipping group) is selected as one object. Repeatedly
|
||||
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
|
||||
// Isolation and Edit Contents change where selection groups begin; keep the original
|
||||
// traversal for those cases rather than change their Select Behind semantics.
|
||||
if opt.scope.is_some() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
let mut tops = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for layer in doc.layers.iter().rev() {
|
||||
// Malformed files may contain non-layer roots; let the general traversal handle them.
|
||||
if !layer.is_layer() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
if !objects_at_layer(layer, p, opt, &mut tops, &mut seen) {
|
||||
// An envelope in Edit Contents may expose objects *within* a top-level group.
|
||||
// The repeated traversal handles that mixed hierarchy precisely.
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
}
|
||||
tops
|
||||
}
|
||||
|
||||
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
|
||||
/// inspected once (only until its first hit). `false` asks the caller to use the general
|
||||
/// algorithm for an envelope whose contents are currently being edited.
|
||||
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
|
||||
let Some(children) = layer.children() else { return true };
|
||||
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return true;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if !may_hit(layer, i, c, p, opt, true) || seen.contains(&c.id) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
if !objects_at_layer(c, p, opt, tops, seen) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Editing an envelope selects its contents rather than the containing object. Even
|
||||
// when nested in a group, the old traversal may select both a member and the group.
|
||||
if c.is_container() {
|
||||
let mut editing = false;
|
||||
c.walk(&mut |n| editing |= edits_contents(n));
|
||||
if editing {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let skip = |id| seen.contains(&id);
|
||||
let mut chain = Vec::new();
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Group { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt).is_some(),
|
||||
_ => hit_leaf(c, p, opt).is_some(),
|
||||
};
|
||||
if hit && seen.insert(c.id) {
|
||||
tops.push(c.id);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The general selection order also handles isolation and nested Edit Contents.
|
||||
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let mut tops: Vec<NodeId> = vec![];
|
||||
// Each round leaves out the objects found so far (and everything inside them).
|
||||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
|
||||
@@ -150,6 +223,22 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
|
||||
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
|
||||
/// isn't shown, or when `p` is outside its reach.
|
||||
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editable_only: bool) -> bool {
|
||||
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
|
||||
return false;
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c) || c.extras().shaper.is_some() && source_isolated(c) || c.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -167,22 +256,7 @@ fn hit_children(
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if parent.extras().shaper.is_some() {
|
||||
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if (i == 0) != construction {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !(edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
if !may_hit(parent, i, c, p, opt, editable_only) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
chain.push(c.id);
|
||||
@@ -619,6 +693,146 @@ world",
|
||||
assert!(at(90.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
|
||||
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
|
||||
/// the per-object walk.
|
||||
#[test]
|
||||
fn objects_at_large_overlapping_stack() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..256 {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
ids.reverse();
|
||||
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
|
||||
}
|
||||
|
||||
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
|
||||
/// invisibility, and path/outline-only settings.
|
||||
#[test]
|
||||
fn objects_at_one_pass_matches_repeated_hits() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let layer = d.layers[0].id;
|
||||
let add = |d: &mut Document, parent, bounds| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
|
||||
id
|
||||
};
|
||||
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
|
||||
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
|
||||
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
|
||||
let clip = d.alloc_id();
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
|
||||
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
|
||||
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
|
||||
clipping.set_clips(true);
|
||||
d.insert(Some(layer), usize::MAX, clipping).unwrap();
|
||||
for (point, opt) in [
|
||||
(Point::new(25.0, 25.0), HitOptions::default()),
|
||||
(Point::new(3.0, 25.0), HitOptions::default()),
|
||||
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
|
||||
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
|
||||
(Point::new(100.0, 100.0), HitOptions::default()),
|
||||
] {
|
||||
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
|
||||
}
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
d.node_mut(group).unwrap().locked = true;
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
}
|
||||
|
||||
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
|
||||
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
|
||||
/// in the same order as the hit test repeated over what is left.
|
||||
#[test]
|
||||
fn objects_at_matches_repeated_hits_on_random_documents() {
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
let mut next = move |n: u32| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed % n
|
||||
};
|
||||
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
|
||||
for _ in 0..2 + next(4) {
|
||||
let id = d.alloc_id();
|
||||
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
|
||||
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
|
||||
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
|
||||
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
|
||||
4 | 5 => Node::group(id, Vec::new()),
|
||||
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
|
||||
7 => {
|
||||
let mut g = Node::group(id, Vec::new());
|
||||
g.set_clips(true);
|
||||
g
|
||||
}
|
||||
_ => Node::path(id, shapes::rectangle(rect), appearance),
|
||||
};
|
||||
n.visible = next(8) != 0;
|
||||
n.locked = next(8) == 0;
|
||||
if let NodeKind::Layer { preview, .. } = &mut n.kind {
|
||||
*preview = next(3) != 0;
|
||||
}
|
||||
let container = n.is_container();
|
||||
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
if container {
|
||||
fill(d, id, depth + 1, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
for round in 0..200 {
|
||||
let mut d = Document::new(120.0, 120.0);
|
||||
let layer = d.layers[0].id;
|
||||
fill(&mut d, layer, 0, &mut next);
|
||||
for _ in 0..12 {
|
||||
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
|
||||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Contents can expose descendants of a top-level group: preserve the original
|
||||
/// selection order by falling back to the general hit traversal.
|
||||
#[test]
|
||||
fn objects_at_edit_contents_uses_general_order() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
|
||||
let (a, b) = (d.alloc_id(), d.alloc_id());
|
||||
let e = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Envelope {
|
||||
content: vec![inner(a), inner(b)],
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: true,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(layer), usize::MAX, e).unwrap();
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
+39
-8
@@ -318,8 +318,10 @@ impl WarpStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
|
||||
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
|
||||
/// proportions).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
use WarpStyle::*;
|
||||
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
|
||||
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
|
||||
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
|
||||
if b.abs() < 1e-6 {
|
||||
(x, y)
|
||||
} else {
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
|
||||
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
|
||||
// the edges are concentric arcs, and one that would pass the centre stops there
|
||||
// instead of folding over (#1578).
|
||||
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
|
||||
let sweep = b.abs() * PI;
|
||||
let r0 = 2.0 / sweep;
|
||||
let yy = if b > 0.0 { y } else { -y };
|
||||
let yy = if b > 0.0 { y } else { -y } * k;
|
||||
let a = x / r0;
|
||||
let r = r0 - yy;
|
||||
let r = (r0 - yy).max(0.0);
|
||||
let (s, c) = a.sin_cos();
|
||||
let (nx, ny) = (r * s, r0 - r * c);
|
||||
let (nx, ny) = (r * s, (r0 - r * c) / k);
|
||||
(nx, if b > 0.0 { ny } else { -ny })
|
||||
}
|
||||
}
|
||||
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
|
||||
Box::new(move |u: f64, v: f64| {
|
||||
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
|
||||
let (x2, y2) = if horizontal {
|
||||
warp_point(st, b, dh, dv, x, y)
|
||||
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
|
||||
} else {
|
||||
let (a, b2) = warp_point(st, b, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
|
||||
(b2, a)
|
||||
};
|
||||
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
@@ -1711,6 +1717,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
#[test]
|
||||
fn arc_warp_is_round_in_real_proportions() {
|
||||
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
|
||||
let r0 = 2.0 / (b * PI);
|
||||
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
|
||||
for i in 0..=10 {
|
||||
let x = -1.0 + 0.2 * f64::from(i);
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
|
||||
let d = nx.hypot(ny * aspect - r0);
|
||||
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// At 100 % on a square the inner edge meets at the centre instead of folding over.
|
||||
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
|
||||
assert!(nx.abs() < 1e-12);
|
||||
// A square bent less than that is what it always was.
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
|
||||
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
|
||||
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
|
||||
}
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
|
||||
|
||||
@@ -249,7 +249,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.freeDistort",
|
||||
"Free Distort…",
|
||||
DT,
|
||||
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
|
||||
),
|
||||
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
let f = |q: Point| {
|
||||
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
|
||||
let (x2, y2) = if vertical {
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
|
||||
(b2, a)
|
||||
} else {
|
||||
warp_point(style, bend, dh, dv, x, y)
|
||||
warp_point(style, bend, dh, dv, hh / hw, x, y)
|
||||
};
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -727,6 +727,16 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
|
||||
live
|
||||
}
|
||||
|
||||
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
|
||||
/// document by `xf`.
|
||||
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
|
||||
/// inside it. Without parents, the nodes as they are.
|
||||
@@ -835,7 +845,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
}
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
let paint = Paint::solid(color);
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
Some(width) => Appearance {
|
||||
|
||||
@@ -280,6 +280,20 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() }))
|
||||
}
|
||||
|
||||
/// The ids of the effects `effect.setParams` with `p` edits, for the check of their parameters
|
||||
/// (none when `p` doesn't say which: running it reports why).
|
||||
pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
const C: &str = "effect.setParams";
|
||||
let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else {
|
||||
return vec![];
|
||||
};
|
||||
let effect = |id: &NodeId| {
|
||||
let ap = &st.doc.node(*id)?.appearance;
|
||||
Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone())
|
||||
};
|
||||
ids.iter().filter_map(effect).collect()
|
||||
}
|
||||
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
@@ -566,7 +580,7 @@ mod tests {
|
||||
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "depth": 120}, "ids": [id.0], "item": null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
//!
|
||||
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
|
||||
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
|
||||
//! command with its default. [`Session::execute`] itself takes what it is given, so the journal of
|
||||
//! an older version still replays.
|
||||
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -150,6 +152,12 @@ fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
|
||||
/// its `…` entries name)?
|
||||
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
|
||||
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
|
||||
}
|
||||
|
||||
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
|
||||
fn distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
|
||||
@@ -183,15 +191,56 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(keys) = accepted(doc, 0) else { return Ok(()) };
|
||||
let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { return Ok(()) };
|
||||
Err(bad(
|
||||
match given.keys().find(|k| !keys.contains(&k.as_str())) {
|
||||
Some(key) => Err(unknown(id, key, &keys)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
bad(
|
||||
id,
|
||||
match nearest(key, &keys) {
|
||||
match nearest(key, keys) {
|
||||
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
|
||||
None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"),
|
||||
None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")),
|
||||
},
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of its
|
||||
/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect
|
||||
/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report.
|
||||
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
|
||||
let defaults = info.defaults.as_object();
|
||||
let mut keys: Vec<&str> = defaults.into_iter().flat_map(|d| d.keys().map(String::as_str)).collect();
|
||||
// A description that doesn't parse names no more keys (the tests rule that out).
|
||||
for key in doc_keys(info.params).map(|d| d.keys).unwrap_or_default() {
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
for (key, v) in given {
|
||||
if !keys.contains(&key.as_str()) {
|
||||
return Err(unknown(id, key, &keys));
|
||||
}
|
||||
match defaults.and_then(|d| d.get(key)) {
|
||||
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
|
||||
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
|
||||
// (A colour is also taken as `[r, g, b]` or as a colour object.)
|
||||
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
|
||||
@@ -327,10 +376,16 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"webExport.settings",
|
||||
];
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` included. A command the
|
||||
/// engine doesn't have passes: running it reports that.
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
|
||||
/// passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
let Some(spec) = find_command(id) else { return Ok(()) };
|
||||
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
|
||||
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
|
||||
{
|
||||
check_effect_params(id, effect, given)?;
|
||||
}
|
||||
if UNCHECKED.contains(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -344,3 +399,18 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
|
||||
/// this session's document (not as a step of a `command.batch`: what it edits is only there
|
||||
/// once the steps before it ran).
|
||||
pub fn check_params(&self, id: &str, params: &Value) -> Result<()> {
|
||||
check_params(id, params)?;
|
||||
if let ("effect.setParams", Some(given)) = (id, params.get("params")) {
|
||||
for effect in super::effectcmd::set_params_effects(self, params) {
|
||||
check_effect_params(id, &effect, given)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -383,10 +383,15 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let mut styles: Vec<Style> = vec![];
|
||||
let mut shapes: Vec<po::Shape> = vec![];
|
||||
let mut names: Vec<Option<String>> = vec![];
|
||||
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
|
||||
let mut push = |mut path: PathData, rule: FillRule, mut st: Style, name: Option<String>| {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Trim, Merge and Crop remove any strokes (and Merge then merges by fill alone), as the
|
||||
// public Pathfinder documentation describes.
|
||||
if matches!(op, PathfinderOp::Trim | PathfinderOp::Merge | PathfinderOp::Crop) {
|
||||
st.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
|
||||
}
|
||||
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
|
||||
// Filled open contours keep their implicit closing edge. A zero-area line
|
||||
// stays a cutter; normalised area also catches zero-signed-area self crossings.
|
||||
@@ -498,6 +503,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
let mut paths = false;
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
@@ -505,6 +511,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut made = vec![];
|
||||
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
|
||||
let Some((path, rule)) = node_path(l) else { continue };
|
||||
paths = true;
|
||||
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
|
||||
let off = po::offset_path(&base, delta, join, miter);
|
||||
if off.is_empty() {
|
||||
@@ -527,6 +534,9 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
}
|
||||
if out.is_empty() && paths {
|
||||
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Offset Path: select paths".into()));
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -1300,16 +1300,35 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
|
||||
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
|
||||
let Some(d) = self.active_mut() else { return (spec.enabled)(self) };
|
||||
let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() });
|
||||
let r = (spec.enabled)(self);
|
||||
if let Some(d) = self.active_mut() {
|
||||
d.selection = old;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
cmd::params::check_params(id, params)?;
|
||||
self.check_params(id, params)?;
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) };
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
|
||||
cmd::has_doc(self)
|
||||
} else if let Some(ids) = given_ids(spec.params, params) {
|
||||
// Objects given as `ids` stand in for the selection: the command is enabled as it is
|
||||
// with them selected (#1575).
|
||||
self.enabled_with_selection(spec, ids)
|
||||
} else {
|
||||
(spec.enabled)(self)
|
||||
};
|
||||
if let Err(why) = enabled {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
@@ -1770,6 +1789,8 @@ mod tests_halftone;
|
||||
#[cfg(test)]
|
||||
mod tests_history_mem;
|
||||
#[cfg(test)]
|
||||
mod tests_ids_without_selection;
|
||||
#[cfg(test)]
|
||||
mod tests_inline;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
@@ -1896,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;
|
||||
@@ -1955,3 +1978,10 @@ mod tests_widthprofiles;
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
||||
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
|
||||
/// key callers outside the app may pass it, [`cmd::params::takes_key`]).
|
||||
fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
|
||||
let ids = cmd::ids_param(params, "ids").filter(|ids| !ids.is_empty())?;
|
||||
cmd::params::takes_key(description, "ids").then_some(ids)
|
||||
}
|
||||
@@ -746,6 +746,33 @@ fn image_trace_auto_grouping_groups_what_is_inside() {
|
||||
assert_eq!(expanded.children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Gradients: an image that is one linear gradient is one path filled with it, the gradient's line
|
||||
/// where the image lies; without the option it is bands of flat colour, as saved documents have it.
|
||||
#[test]
|
||||
fn image_trace_gradients_fills_with_a_gradient() {
|
||||
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
|
||||
let t = (f64::from(x) + 0.5) / 100.0;
|
||||
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
|
||||
});
|
||||
let make = |gradients: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": gradients}})).unwrap();
|
||||
traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())))
|
||||
};
|
||||
let paths = make(true);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
|
||||
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Linear);
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
// Level, from the image's left edge (x = 50) to its right one (x = 250), at any height.
|
||||
let geom = g.geom.as_ref().unwrap();
|
||||
let (a, b) = if geom.start.x < geom.end.x { (geom.start, geom.end) } else { (geom.end, geom.start) };
|
||||
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
|
||||
let flat = make(false);
|
||||
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_release_and_expand() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! #1575: commands that take `ids` work on the objects given, with nothing selected; the
|
||||
//! selection is left as it was.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn doc_with_rect() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stand_in_for_the_selection() {
|
||||
for (cmd, params) in [
|
||||
("object.repeat.radial", json!({})),
|
||||
("edit.colors.invert", json!({})),
|
||||
("object.pattern.make", json!({"name": "P"})),
|
||||
("symbol.new", json!({"name": "S"})),
|
||||
("object.mesh.create", json!({})),
|
||||
("object.makePixelPerfect", json!({})),
|
||||
] {
|
||||
let (mut s, id) = doc_with_rect();
|
||||
let mut p = params;
|
||||
p["ids"] = json!([id]);
|
||||
let r = s.execute(cmd, &p);
|
||||
assert!(r.is_ok(), "{cmd}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_put_back_and_the_check_still_applies() {
|
||||
let (mut s, id) = doc_with_rect();
|
||||
// Disabled even with the object given: Make with Top Object needs two.
|
||||
let r = s.execute("object.envelope.makeWithTopObject", &json!({"ids": [id]}));
|
||||
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "the stand-in selection is put back");
|
||||
// Without ids, nothing selected is still nothing selected.
|
||||
let r = s.execute("object.repeat.radial", &json!({}));
|
||||
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_ids_key_counts() {
|
||||
let p = json!({"ids": [3]});
|
||||
assert_eq!(given_ids("{ids?: [id]}", &p), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{id?|ids?, name}", &p), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{grids: n, guides: bool}", &p), None);
|
||||
// A word in what the description says about another key isn't the key.
|
||||
assert_eq!(given_ids("{name: of the swatch the ids of the selection take}", &p), None);
|
||||
assert_eq!(given_ids("{ids?: [id]}", &json!({"ids": []})), None);
|
||||
}
|
||||
|
||||
/// Every command that names `ids` and is refused with nothing selected: given the object as `ids`
|
||||
/// it does what it does with the object selected (the same result or the same error, and the same
|
||||
/// document), so no command is let through to run on an empty selection.
|
||||
#[test]
|
||||
fn ids_do_what_the_selection_does_in_every_command_that_takes_them() {
|
||||
let mut checked = vec![];
|
||||
for spec in command_specs() {
|
||||
let (mut given, id) = doc_with_rect();
|
||||
if !cmd::params::takes_key(spec.params, "ids") || (spec.enabled)(&given).is_ok() {
|
||||
continue;
|
||||
}
|
||||
let (mut selected, _) = doc_with_rect();
|
||||
selected.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let (a, b) = (given.execute(spec.id, &json!({"ids": [id]})), selected.execute(spec.id, &json!({})));
|
||||
match (&a, &b) {
|
||||
(Ok(_), Ok(_)) => {}
|
||||
(Err(x), Err(y)) => assert_eq!(x.to_string(), y.to_string(), "{}", spec.id),
|
||||
_ => panic!("{}: with ids {a:?}, with the selection {b:?}", spec.id),
|
||||
}
|
||||
match (given.doc(), selected.doc()) {
|
||||
(Ok(g), Ok(s)) => assert!(g.doc == s.doc, "{}: the documents differ", spec.id),
|
||||
(g, s) => assert_eq!(g.is_ok(), s.is_ok(), "{}", spec.id),
|
||||
}
|
||||
checked.push((spec.id, a.is_ok()));
|
||||
}
|
||||
assert!(checked.iter().filter(|c| c.1).count() >= 20, "{checked:?}");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::params::{UNCHECKED, check_keys, check_params, doc_keys};
|
||||
use crate::cmd::params::{UNCHECKED, check_effect_params, check_keys, check_params, doc_keys};
|
||||
use crate::{EngineError, Session, command_specs, find_command};
|
||||
|
||||
fn keys(doc: &str) -> Vec<&str> {
|
||||
@@ -95,6 +95,50 @@ fn a_batch_checks_its_steps() {
|
||||
assert_eq!(refusal("command.batch", json!({"steps": []})), "unknown parameter `steps`; the command takes: label, commands, artboard");
|
||||
}
|
||||
|
||||
/// #1592: the `params` of an effect are held to the effect's own keys and types, where the effect
|
||||
/// is named (`effect.apply`) and where the document says which it is (`effect.setParams`).
|
||||
#[test]
|
||||
fn effect_params_are_the_effects_own() {
|
||||
use vectorcraft_render::effects::effect_catalog;
|
||||
for e in effect_catalog() {
|
||||
doc_keys(e.params).unwrap_or_else(|why| panic!("{}: {why}", e.id));
|
||||
check_effect_params("effect.apply", e.id, &e.defaults).unwrap_or_else(|why| panic!("{}'s defaults: {why}", e.id));
|
||||
}
|
||||
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
|
||||
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"blurr": 3}))), "unknown parameter `blurr`; the command takes: radius");
|
||||
assert_eq!(
|
||||
refusal("appearance.addEffect", apply("stylize.dropShadow", json!({"opacty": 50}))),
|
||||
"unknown parameter `opacty`; did you mean `opacity`?"
|
||||
);
|
||||
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"radius": "big"}))), "`radius` must be a number");
|
||||
assert_eq!(refusal("effect.apply", apply("threeD.revolve", json!({"shade": 1}))), "`shade` must be true or false");
|
||||
assert_eq!(refusal("effect.apply", apply("threeD.revolve", json!({"edge": 1}))), "`edge` must be a string");
|
||||
// Out of range is the effect's to clamp; a key only its description names is one it takes;
|
||||
// a plug-in's are its manifest's; an unknown effect is the command's to report.
|
||||
for (effect, params) in [
|
||||
("stylize.dropShadow", json!({"opacity": 500, "x": -3, "color": "#ff0000"})),
|
||||
("stylize.dropShadow", json!({"color": [1, 0, 0]})),
|
||||
("threeD.revolve", json!({"segments": 100000})),
|
||||
("path.outlineStroke", json!({"width": 2})),
|
||||
("plugin.anything", json!({"whatever": 1})),
|
||||
("no.such", json!({"whatever": 1})),
|
||||
] {
|
||||
assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}");
|
||||
}
|
||||
// `effect.setParams` names an index: the session knows the effect there.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
s.execute("effect.apply", &apply("blur.gaussian", json!({"radius": 2}))).unwrap();
|
||||
for (params, why) in [(json!({"blurr": 3}), "unknown parameter `blurr`"), (json!({"radius": true}), "`radius` must be a number")] {
|
||||
let e = s.execute("effect.setParams", &json!({"index": 0, "params": params})).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{e}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"radius": 1e9}, "visible": false})).unwrap();
|
||||
// No effect at the index: the command says so, not the check.
|
||||
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
|
||||
}
|
||||
|
||||
/// What a command notes for its journal entry is a key its description names, so the entry
|
||||
/// passes the check a caller's params get.
|
||||
#[test]
|
||||
|
||||
@@ -893,3 +893,40 @@ fn divide_keeps_implicitly_closed_filled_paths_including_zero_signed_area() {
|
||||
assert!(pieces.iter().all(|n| n.appearance == source.appearance));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1570: Trim, Merge and Crop remove the objects' strokes; Merge then merges same-coloured
|
||||
/// fills whatever their strokes were.
|
||||
#[test]
|
||||
fn trim_merge_and_crop_remove_strokes() {
|
||||
for op in ["trim", "merge", "crop"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two_rects(&mut s);
|
||||
for (id, c) in [(a, Color::rgb(0.0, 0.0, 0.0)), (b, Color::rgb(1.0, 0.0, 0.0))] {
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(id).unwrap().appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(c), 3.0).into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
let pieces = g.children().unwrap();
|
||||
assert!(!pieces.is_empty(), "{op}");
|
||||
assert!(pieces.iter().all(|c| c.appearance.stroke_width() == 0.0 && c.appearance.fill().is_some()), "{op} keeps a stroke");
|
||||
if op == "merge" {
|
||||
assert_eq!(pieces.len(), 1, "same fill, different strokes: merged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1590: an inset larger than the shape leaves nothing, and the error says so rather than asking
|
||||
/// for paths that are selected.
|
||||
#[test]
|
||||
fn offset_path_that_leaves_nothing_says_so() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let e = s.execute("object.path.offsetPath", &json!({"offset": -20})).unwrap_err().to_string();
|
||||
assert!(e.contains("leaves nothing") && !e.contains("select paths"), "{e}");
|
||||
}
|
||||
@@ -106,10 +106,19 @@ fn listed_applied_edited_and_undone() {
|
||||
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
|
||||
}
|
||||
}
|
||||
// Junk parameters are stored as given and read clamped; the canvas still draws.
|
||||
// Numbers far out of range are stored as given and read clamped; the canvas still draws.
|
||||
for (id, _) in EFFECTS {
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
|
||||
.unwrap();
|
||||
let defaults = vectorcraft_render::effects::default_params(id).unwrap();
|
||||
let junk: serde_json::Map<String, Value> = defaults
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, v)| v.is_number())
|
||||
.enumerate()
|
||||
.map(|(i, (k, _))| (k.clone(), json!(if i % 2 == 0 { 1e308 } else { -1e308 })))
|
||||
.collect();
|
||||
assert!(!junk.is_empty(), "{id}");
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": junk})).unwrap();
|
||||
}
|
||||
let _ = render(&s);
|
||||
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -22,6 +22,28 @@ fn a_journal_with_a_key_no_longer_read_still_replays() {
|
||||
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1));
|
||||
}
|
||||
|
||||
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
|
||||
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
|
||||
#[test]
|
||||
fn effect_params_no_longer_taken_still_replay_and_open() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let old = [
|
||||
("effect.apply", json!({"effect": "blur.gaussian", "params": {"blurr": 3, "radius": "big"}})),
|
||||
("effect.setParams", json!({"index": 0, "params": {"amount": 1e308}})),
|
||||
];
|
||||
for (id, params) in &old {
|
||||
assert!(s.check_params(id, params).is_err(), "{id}");
|
||||
s.execute(id, params).unwrap();
|
||||
}
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
let stored = |d: &vectorcraft_doc::Document| d.layers[0].children().unwrap()[0].appearance.effects[0].params.clone();
|
||||
assert_eq!(stored(&back), stored(doc));
|
||||
assert_eq!(stored(&back)["blurr"], json!(3));
|
||||
}
|
||||
|
||||
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
|
||||
/// sent back through the control channel or MCP.
|
||||
#[test]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Headless {
|
||||
// A key the command's description doesn't name is an error, not a default (#1442).
|
||||
match HOST_COMMANDS.iter().find(|c| c.0 == id) {
|
||||
Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params),
|
||||
None => vectorcraft_engine::check_params(id, params),
|
||||
None => self.session.check_params(id, params),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
match id {
|
||||
|
||||
@@ -1241,11 +1241,50 @@ impl<'p> Builder<'p> {
|
||||
}
|
||||
|
||||
fn flush_text(&mut self) {
|
||||
let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.lines.insert(n.id, facts);
|
||||
self.push_node(n);
|
||||
let Some(mut line) = self.text.take() else { return };
|
||||
let held = std::mem::take(&mut line.held);
|
||||
if let Some((t, opacity, facts)) = line.finish() {
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.lines.insert(n.id, facts);
|
||||
self.push_node(n);
|
||||
}
|
||||
// The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke).
|
||||
for n in held {
|
||||
self.push_node(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph
|
||||
/// before it on the open line was stroked so too, is held with the line instead of ending it
|
||||
/// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the
|
||||
/// type). `false`: it isn't one.
|
||||
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool {
|
||||
let PathDrawMode::Stroke(props) = draw_mode else { return false };
|
||||
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) {
|
||||
return false;
|
||||
}
|
||||
let (p, opacity) = self.paint(paint, true);
|
||||
let Some(line) = &self.text else { return false };
|
||||
let Some(ink) = line.ink.last() else { return false };
|
||||
if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) {
|
||||
return false;
|
||||
}
|
||||
let mut bp = path.clone();
|
||||
bp.apply_affine(transform);
|
||||
let b = bp.bounding_box();
|
||||
let tol = 0.01 * (ink.width() + ink.height()) + 0.01;
|
||||
if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) {
|
||||
return false;
|
||||
}
|
||||
let mut st = stroke_layer(p, opacity, props, mean_scale(transform));
|
||||
// Every painted path takes its turn in the scan's overprint list, a held one too.
|
||||
st.overprint = self.path_overprint();
|
||||
let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||||
if let Some(line) = &mut self.text {
|
||||
line.held.push(n);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
|
||||
@@ -1667,6 +1706,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
|
||||
self.flush_glyphs();
|
||||
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
|
||||
self.pending = false;
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
let overprint = self.path_overprint();
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Node, ParaDirection, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_text::TextLayout;
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
@@ -144,6 +144,9 @@ pub(crate) struct TextLine {
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
|
||||
advances: Vec<f64>,
|
||||
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
|
||||
/// (written right after the line).
|
||||
pub held: Vec<Node>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
@@ -177,9 +180,15 @@ impl TextLine {
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
advances: vec![],
|
||||
held: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// The paint the last glyph is filled with.
|
||||
pub fn last_fill(&self) -> Option<&Paint> {
|
||||
self.runs.last()?.0.fill.as_ref()
|
||||
}
|
||||
|
||||
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
|
||||
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
|
||||
/// another line.
|
||||
@@ -350,6 +359,20 @@ impl TextLine {
|
||||
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
|
||||
}
|
||||
if let Some(path) = path {
|
||||
// Evenly spaced type keeps its length along the path: the path runs through the
|
||||
// glyphs' origins, one curve each, to the end of the last glyph, and tracking makes up
|
||||
// the difference from the advances.
|
||||
if own.is_none() {
|
||||
let length: f64 = path.segments().take(self.baseline.len()).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).sum();
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size);
|
||||
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
|
||||
}
|
||||
}
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
|
||||
@@ -153,6 +153,25 @@ fn a_stroke_on_the_type_of_a_moved_ai_page_is_still_the_types_stroke() {
|
||||
assert!(matches!(art[1].appearance.items.as_slice(), [AppearanceItem::Stroke(_)]), "{:?}", art[1].appearance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyphs_each_followed_by_their_stroked_outline_stay_one_object() {
|
||||
// Type on a path is written glyph by glyph: each glyph, then its outline stroked in the same
|
||||
// colour (a shadow under the type).
|
||||
let glyph = |c: char, x: f64, stroke: &str| format!("BT 0 0 1 rg /F1 24 Tf {x} 50 Td ({c}) Tj ET {stroke} 0.5 w {}", outline(c, x));
|
||||
let i_at = 10.0 + 0.722 * 24.0;
|
||||
let d = import(&page(&(glyph('H', 10.0, "0 0 1 RG") + &glyph('i', i_at, "0 0 1 RG")))).unwrap();
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one object: {art:?}");
|
||||
let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) };
|
||||
assert_eq!(t.plain_text(), "Hi");
|
||||
let [AppearanceItem::Stroke(st)] = art[0].appearance.items.as_slice() else { panic!("{:?}", art[0].appearance) };
|
||||
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// A stroke of another colour drawn over each glyph in turn would also cover the next one's
|
||||
// neighbour as one object's stroke: the glyphs stay as the file draws them.
|
||||
let d = import(&page(&(glyph('H', 10.0, "1 1 1 RG") + &glyph('i', i_at, "1 1 1 RG")))).unwrap();
|
||||
assert_eq!(texts(&d).len(), 2);
|
||||
}
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
|
||||
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
|
||||
crate::tests_live_text::text_pdf(lines, false)
|
||||
|
||||
@@ -424,3 +424,22 @@ fn glyphs_spaced_each_their_own_way_keep_their_place() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Evenly tracked type on a path comes back with its tracking: each glyph where it was along the
|
||||
/// curve, not closed up to the font's advances.
|
||||
#[test]
|
||||
fn evenly_tracked_type_on_a_path_keeps_its_length() {
|
||||
let mut arc = BezPath::new();
|
||||
arc.move_to((40.0, 200.0));
|
||||
arc.curve_to((100.0, 80.0), (260.0, 80.0), (320.0, 200.0));
|
||||
let mut t = TextObject::point(Point::ORIGIN, "ARCHING TYPE", CharStyle { tracking: 80.0, ..style(24.0) });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
|
||||
let d = doc(vec![t]);
|
||||
let want = origins(&d);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
||||
let got = origins(&back);
|
||||
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!(g.distance(*w) < 0.3, "{got:?}\nvs {want:?}");
|
||||
}
|
||||
}
|
||||
@@ -341,9 +341,11 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
|
||||
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
|
||||
// k-means: move each entry to the mean of the cells nearest to it.
|
||||
for _ in 0..3 {
|
||||
// Centres move between passes, not within one: build the spatial index once per pass.
|
||||
let index = PaletteIndex::new(&palette, w);
|
||||
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
|
||||
for c in &cells {
|
||||
let i = nearest(&palette, c.mean, w);
|
||||
let i = index.nearest(c.mean);
|
||||
for ch in 0..3 {
|
||||
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
|
||||
}
|
||||
@@ -371,8 +373,137 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
|
||||
out
|
||||
}
|
||||
|
||||
fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
|
||||
palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
|
||||
/// An exact 3-D nearest-neighbour index for the weighted RGB distance used by palette export.
|
||||
///
|
||||
/// Balanced at the weighted widest axis. Every subtree carries tight channel bounds, so a
|
||||
/// branch-and-bound search skips it when even its nearest *possible* colour is farther than the
|
||||
/// best one found. Equal distances still choose the earliest palette index (as a linear scan did).
|
||||
/// For very small palettes a scan is cheaper than traversing the tree.
|
||||
struct PaletteNode {
|
||||
index: usize,
|
||||
lo: [f32; 3],
|
||||
hi: [f32; 3],
|
||||
left: Option<usize>,
|
||||
right: Option<usize>,
|
||||
}
|
||||
|
||||
struct PaletteIndex<'a> {
|
||||
colors: &'a [[f32; 3]],
|
||||
weights: [f32; 3],
|
||||
nodes: Vec<PaletteNode>,
|
||||
root: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> PaletteIndex<'a> {
|
||||
const SCAN_CUTOFF: usize = 12;
|
||||
|
||||
fn new(colors: &'a [[f32; 3]], weights: [f32; 3]) -> Self {
|
||||
let mut nodes = if colors.len() <= Self::SCAN_CUTOFF { Vec::new() } else { Vec::with_capacity(colors.len()) };
|
||||
let root = if colors.len() <= Self::SCAN_CUTOFF {
|
||||
None
|
||||
} else {
|
||||
let mut indices: Vec<usize> = (0..colors.len()).collect();
|
||||
Self::build(colors, weights, &mut indices, &mut nodes)
|
||||
};
|
||||
Self { colors, weights, nodes, root }
|
||||
}
|
||||
|
||||
// Median partitioning takes O(k log k) time and space for k palette entries.
|
||||
fn build(colors: &[[f32; 3]], w: [f32; 3], ids: &mut [usize], nodes: &mut Vec<PaletteNode>) -> Option<usize> {
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
for &id in ids.iter() {
|
||||
let Some(c) = colors.get(id) else { continue };
|
||||
for axis in 0..3 {
|
||||
lo[axis] = lo[axis].min(c[axis]);
|
||||
hi[axis] = hi[axis].max(c[axis]);
|
||||
}
|
||||
}
|
||||
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
|
||||
let mid = ids.len() / 2;
|
||||
ids.select_nth_unstable_by(mid, |&a, &b| {
|
||||
let ac = colors.get(a).map_or(0.0, |c| c[axis]);
|
||||
let bc = colors.get(b).map_or(0.0, |c| c[axis]);
|
||||
ac.total_cmp(&bc).then(a.cmp(&b))
|
||||
});
|
||||
let (before, rest) = ids.split_at_mut(mid);
|
||||
let (pivot, after) = rest.split_first_mut()?;
|
||||
let at = nodes.len();
|
||||
nodes.push(PaletteNode { index: *pivot, lo, hi, left: None, right: None });
|
||||
let left = Self::build(colors, w, before, nodes);
|
||||
let right = Self::build(colors, w, after, nodes);
|
||||
if let Some(n) = nodes.get_mut(at) {
|
||||
n.left = left;
|
||||
n.right = right;
|
||||
}
|
||||
Some(at)
|
||||
}
|
||||
|
||||
// Lower bound on *the same f32 weighted squared distance* as `dist`: avoid transforming
|
||||
// coordinates, which would change rounding and potentially break identical-palette ties.
|
||||
fn lower_bound(&self, id: usize, c: [f32; 3]) -> f32 {
|
||||
let Some(n) = self.nodes.get(id) else { return f32::INFINITY };
|
||||
(0..3)
|
||||
.map(|i| {
|
||||
let gap = if c[i] < n.lo[i] {
|
||||
n.lo[i] - c[i]
|
||||
} else if c[i] > n.hi[i] {
|
||||
c[i] - n.hi[i]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(self.weights[i] * gap).powi(2)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn search(&self, at: usize, c: [f32; 3], best: &mut (f32, usize)) {
|
||||
let Some(n) = self.nodes.get(at) else { return };
|
||||
let Some(&p) = self.colors.get(n.index) else { return };
|
||||
let d = dist(p, c, self.weights);
|
||||
if d < best.0 || (d == best.0 && n.index < best.1) {
|
||||
*best = (d, n.index);
|
||||
}
|
||||
// Search the more promising box first, tightening the bound for the second box.
|
||||
let left = n.left.map(|id| (id, self.lower_bound(id, c)));
|
||||
let right = n.right.map(|id| (id, self.lower_bound(id, c)));
|
||||
match (left, right) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (first, second) = if a.1 <= b.1 { (a, b) } else { (b, a) };
|
||||
if first.1 <= best.0 {
|
||||
self.search(first.0, c, best);
|
||||
}
|
||||
// `<=` is essential: an equal-distance colour may have an earlier index.
|
||||
if second.1 <= best.0 {
|
||||
self.search(second.0, c, best);
|
||||
}
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => {
|
||||
if a.1 <= best.0 {
|
||||
self.search(a.0, c, best);
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest(&self, c: [f32; 3]) -> usize {
|
||||
if self.colors.len() <= Self::SCAN_CUTOFF {
|
||||
return self
|
||||
.colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|a, b| dist(*a.1, c, self.weights).total_cmp(&dist(*b.1, c, self.weights)))
|
||||
.map_or(0, |(i, _)| i);
|
||||
}
|
||||
let mut best = (f32::INFINITY, 0);
|
||||
if let Some(root) = self.root {
|
||||
self.search(root, c, &mut best);
|
||||
}
|
||||
best.1
|
||||
}
|
||||
}
|
||||
|
||||
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
|
||||
@@ -398,19 +529,20 @@ fn noise(x: usize, y: usize) -> f32 {
|
||||
/// names (transparent) get index 0 and take no diffused error.
|
||||
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
|
||||
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
|
||||
let index = PaletteIndex::new(&pal, w);
|
||||
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
|
||||
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
|
||||
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
|
||||
let mut lookup = |c: [f32; 3]| -> u8 {
|
||||
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
|
||||
if grid.is_empty() {
|
||||
return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8);
|
||||
return *exact.entry(q).or_insert_with(|| index.nearest(c) as u8);
|
||||
}
|
||||
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
|
||||
match grid.get(key) {
|
||||
Some(&v) if v != u16::MAX => v as u8,
|
||||
_ => {
|
||||
let i = nearest(&pal, c, w) as u8;
|
||||
let i = index.nearest(c) as u8;
|
||||
if let Some(g) = grid.get_mut(key) {
|
||||
*g = i as u16;
|
||||
}
|
||||
@@ -466,3 +598,83 @@ fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dithe
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nearest_tests {
|
||||
use super::*;
|
||||
|
||||
// The original definition, retained only as a parity oracle for the spatial index.
|
||||
fn linear(colors: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
|
||||
colors.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
|
||||
}
|
||||
|
||||
fn next(seed: &mut u32) -> u32 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 17;
|
||||
*seed ^= *seed << 5;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_nearest_matches_linear_for_palette_sizes_and_weights() {
|
||||
let mut seed = 0x2ab4_1017;
|
||||
for w in [[1.0; 3], [0.55, 0.77, 0.34], [0.1, 1.0, 0.01]] {
|
||||
for size in [0, 1, 2, 8, 12, 13, 16, 17, 32, 64, 128, 256] {
|
||||
// Include duplicated colours, close k-means-style floating-point centres,
|
||||
// and a wide colour gamut. Query points are fractional, as with dithering.
|
||||
let colors: Vec<[f32; 3]> =
|
||||
(0..size)
|
||||
.map(|i| {
|
||||
if i % 11 == 0 {
|
||||
[127.0, 127.0, 127.0]
|
||||
} else {
|
||||
[0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let index = PaletteIndex::new(&colors, w);
|
||||
for _ in 0..500 {
|
||||
let c = [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0));
|
||||
assert_eq!(index.nearest(c), linear(&colors, c, w), "size {size}, weights {w:?}, query {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_distance_always_chooses_first_palette_entry() {
|
||||
// Enough entries to exercise the tree, even when they share one bounding box.
|
||||
let mut colors = vec![[128.0, 128.0, 128.0]; 256];
|
||||
colors[0] = [0.0, 128.0, 128.0];
|
||||
colors[255] = [2.0, 128.0, 128.0];
|
||||
let index = PaletteIndex::new(&colors, [1.0; 3]);
|
||||
assert_eq!(index.nearest([1.0, 128.0, 128.0]), 0, "equal distance to indices 0 and 255");
|
||||
assert_eq!(index.nearest([128.0; 3]), 1, "equal among 254 identical colours");
|
||||
let colors = vec![[10.0, 10.0, 10.0]; 256];
|
||||
assert_eq!(PaletteIndex::new(&colors, [0.55, 0.77, 0.34]).nearest([10.0; 3]), 0);
|
||||
}
|
||||
|
||||
/// Opt-in microbenchmark: compares the same nearest-neighbour queries, without image IO.
|
||||
#[test]
|
||||
#[ignore = "run with cargo test -p vectorcraft-render nearest_palette_benchmark --release -- --ignored --nocapture"]
|
||||
fn nearest_palette_benchmark() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut seed = 0x87bb_7641;
|
||||
let colors: Vec<[f32; 3]> = (0..256).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 256) as f32)).collect();
|
||||
let queries: Vec<[f32; 3]> = (0..100_000).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))).collect();
|
||||
let w = [0.55, 0.77, 0.34];
|
||||
let start = Instant::now();
|
||||
let baseline: Vec<_> = queries.iter().map(|&c| linear(&colors, c, w)).collect();
|
||||
let old = start.elapsed();
|
||||
let start = Instant::now();
|
||||
let index = PaletteIndex::new(&colors, w);
|
||||
let improved: Vec<_> = queries.iter().map(|&c| index.nearest(c)).collect();
|
||||
let new = start.elapsed();
|
||||
assert_eq!(baseline, improved);
|
||||
eprintln!(
|
||||
"100k nearest-colour queries, 256 entries: linear {old:?}, spatial index {new:?}, speedup {:.2}x",
|
||||
old.as_secs_f64() / new.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
+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![]
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -175,7 +175,8 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war
|
||||
let cx = Ctx::new(xml, dpi);
|
||||
let mut out = String::new();
|
||||
let mut copied = 0;
|
||||
for t in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text" && n.tag_name().namespace() == Some(SVG_NS)) {
|
||||
let is_text = |n: &XNode| n.is_element() && matches!(n.tag_name().name(), "text" | "flowRoot") && n.tag_name().namespace() == Some(SVG_NS);
|
||||
for t in xml.descendants().filter(is_text) {
|
||||
let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
|
||||
if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") {
|
||||
warnings.push(format!("{label} in a clip path ignored"));
|
||||
@@ -191,11 +192,13 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(q) = qname.strip_suffix("text").filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
|
||||
let flowed = t.tag_name().name() == "flowRoot";
|
||||
let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
|
||||
if cx.css.own(t, "display").as_deref() == Some("none") || r.start < copied {
|
||||
continue;
|
||||
}
|
||||
let Some((text, paints)) = cx.build(xml, t, &label, warnings) else { continue };
|
||||
let built = if flowed { cx.build_flow(t, &label, warnings) } else { cx.build(xml, t, &label, warnings) };
|
||||
let Some((text, paints)) = built else { continue };
|
||||
out.push_str(&svg[copied..r.start]);
|
||||
placeholder(&mut out, q, &cx.css, t, &slots.prefix, slots.texts.len(), text.marker, &paints);
|
||||
copied = r.end;
|
||||
@@ -560,6 +563,47 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one Inkscape `<flowRoot>` (SVG 1.2 flowed text, which browsers don't draw but
|
||||
/// Inkscape writes) as area type: its frame the first `<rect>` of its `<flowRegion>`, a
|
||||
/// paragraph per `<flowPara>`, styled as `<text>` is. Other regions are left out with a warning.
|
||||
fn build_flow(&self, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
|
||||
let (vw, vh) = self.viewport;
|
||||
let region = t.children().find(|c| c.is_element() && c.tag_name().name() == "flowRegion");
|
||||
let rect = region.and_then(|r| r.children().find(|c| c.is_element() && c.tag_name().name() == "rect"));
|
||||
let size = |n: XNode, a: &str, pct: f64| n.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct));
|
||||
let frame = rect.and_then(|r| {
|
||||
let (w, h) = (size(r, "width", vw)?, size(r, "height", vh)?);
|
||||
(w > 0.0 && h > 0.0).then(|| (r, Rect::new(0.0, 0.0, w, h)))
|
||||
});
|
||||
let Some((r, frame)) = frame else {
|
||||
warnings.push(format!("{label}: flowed text (flowRoot) without a rectangle region was left out"));
|
||||
return None;
|
||||
};
|
||||
let origin = Affine::translate((size(r, "x", vw).unwrap_or(0.0), size(r, "y", vh).unwrap_or(0.0)));
|
||||
let paras: Vec<XNode> = t.descendants().filter(|n| n.is_element() && n.tag_name().name() == "flowPara").collect();
|
||||
let collapse = |n: XNode| {
|
||||
n.descendants().filter(XNode::is_text).filter_map(|c| c.text()).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
let text = paras.iter().map(|p| collapse(*p)).collect::<Vec<_>>().join("\n");
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let style = self.elem(paras.first().copied().unwrap_or(t)).style.clone();
|
||||
let mut obj = TextObject::point(Point::ZERO, &text, style);
|
||||
obj.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(frame) };
|
||||
obj.xf = parse_transform(r.attribute("transform")) * origin;
|
||||
let servers = vec![(None, None); obj.runs.len()];
|
||||
let b = obj.xf.transform_rect_bbox(frame);
|
||||
// A size, place or transform past what numbers hold (`width="1e999"`) has no frame to set type in.
|
||||
if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) {
|
||||
warnings.push(format!("{label}: flowed text (flowRoot) with a region too large to place was left out"));
|
||||
return None;
|
||||
}
|
||||
let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0));
|
||||
let name = t.attribute("id").unwrap_or("").to_string();
|
||||
Some((PendingText { name, obj, servers, paints: 0, non_scaling: false, under: None, marker }, Vec::new()))
|
||||
}
|
||||
|
||||
/// Read one `<text>`: the pending text and its `url(#…)` paints.
|
||||
fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
|
||||
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
|
||||
|
||||
@@ -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"/>"#)
|
||||
}
|
||||
@@ -594,3 +594,38 @@ fn the_font_shorthand_styles_type() {
|
||||
let style = st.font_style.to_ascii_lowercase();
|
||||
assert!(style.contains("bold") && style.contains("italic"), "{style}");
|
||||
}
|
||||
|
||||
/// Inkscape's flowed text (`<flowRoot>`) becomes area type in its region's rectangle, a paragraph
|
||||
/// per `<flowPara>`; another kind of region is left out with a warning, not silently.
|
||||
#[test]
|
||||
fn flowed_text_becomes_area_type() {
|
||||
let (d, w) = import_with_report(&svg(
|
||||
r#"<flowRoot style="font-size:16px;font-family:sans-serif"><flowRegion><rect x="20" y="30" width="100" height="160"/></flowRegion><flowPara>This is flowed text</flowPara><flowPara>Second <flowSpan>one</flowSpan></flowPara></flowRoot>
|
||||
<flowRoot><flowRegion><path d="M0 0 L10 0 L5 10 Z"/></flowRegion><flowPara>Lost</flowPara></flowRoot>"#,
|
||||
))
|
||||
.unwrap();
|
||||
let t = only_text(&d);
|
||||
assert_eq!(t.plain_text(), "This is flowed text\nSecond one");
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let b = t.xf.transform_rect_bbox(frame.bounds().unwrap());
|
||||
assert!(close(b.x0, 20.0, 1e-6) && close(b.y0, 30.0, 1e-6) && close(b.x1, 120.0, 1e-6) && close(b.y1, 190.0, 1e-6), "{b:?}");
|
||||
assert!(close(t.runs[0].style.size * t.xf.determinant().abs().sqrt(), 16.0, 1e-6));
|
||||
assert!(w.iter().any(|w| w.contains("flowRoot")), "{w:?}");
|
||||
}
|
||||
|
||||
/// Flowed text that can't be read is left out with a warning each, and never stops the import: no
|
||||
/// region, a region too large for numbers, a flowRoot inside a flowRoot (read once, as the outer one).
|
||||
#[test]
|
||||
fn flowed_text_that_cannot_be_read_is_reported() {
|
||||
let (d, w) = import_with_report(&svg(
|
||||
r#"<flowRoot id="none"><flowPara>No region</flowPara></flowRoot>
|
||||
<flowRoot id="huge"><flowRegion><rect width="1e999" height="10"/></flowRegion><flowPara>Huge</flowPara></flowRoot>
|
||||
<flowRoot id="far"><flowRegion><rect x="1e308" width="1e308" height="10" transform="scale(1e308)"/></flowRegion><flowPara>Far</flowPara></flowRoot>
|
||||
<flowRoot id="outer"><flowRegion><rect width="50" height="50"/></flowRegion><flowPara>Outer</flowPara><flowRoot><flowRegion><rect width="5" height="5"/></flowRegion><flowPara>inner</flowPara></flowRoot></flowRoot>"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(only_text(&d).plain_text(), "Outer\ninner");
|
||||
for id in ["none", "huge", "far"] {
|
||||
assert!(w.iter().any(|w| w.contains(id) && w.contains("flowRoot")), "{id}: {w:?}");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
+10
-1
@@ -54,9 +54,13 @@ fn before(a: Point, b: Point) -> std::cmp::Ordering {
|
||||
/// Lines within this of level or upright are made exactly so (the tangent of 2.5°).
|
||||
const NEARLY_UPRIGHT: f64 = 0.0437;
|
||||
|
||||
/// The farthest (px) levelling a line may move either of its ends.
|
||||
const LEVEL_REACH: f64 = 1.6;
|
||||
|
||||
/// Snap Curves To Lines: the straight pieces of `segs` (a chain; `closed` when its end joins its
|
||||
/// start) that are within 2.5° of level or upright and at least 2 px long are made exactly level
|
||||
/// or upright, by moving their ends across them: both to their mean, or, in an open chain, the
|
||||
/// or upright (unless that moves an end by more than [`LEVEL_REACH`]), by moving their ends
|
||||
/// across them: both to their mean, or, in an open chain, the
|
||||
/// free end to the end that is fixed (with `fixed_ends`, a chain's own ends stay where they are:
|
||||
/// they are junctions other regions share). The pieces next to a moved end follow it.
|
||||
fn snap_upright(segs: &mut [Cubic], closed: bool, fixed_ends: bool) {
|
||||
@@ -96,6 +100,11 @@ fn snap_upright(segs: &mut [Cubic], closed: bool, fixed_ends: bool) {
|
||||
(false, true) => pick(b),
|
||||
(false, false) => (pick(a) + pick(b)) / 2.0,
|
||||
};
|
||||
// Not when an end would go farther than a pixel and a half or so from where the image has it: a long line a
|
||||
// degree or two off is that line, not a level one drawn badly.
|
||||
if (pick(a) - to).abs().max((pick(b) - to).abs()) > LEVEL_REACH {
|
||||
continue;
|
||||
}
|
||||
let moved = |p: Point| if level { Point::new(p.x, to) } else { Point::new(to, p.y) };
|
||||
if !fixed(ia) {
|
||||
put(segs, ia, moved(a));
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Gradients: areas whose colour changes evenly along one direction are found before the image is
|
||||
//! reduced to its palette, and each is traced as one path filled with a linear gradient instead of
|
||||
//! as bands of flat colour.
|
||||
//!
|
||||
//! An area is a connected run of pixels that each differ little from their neighbours. It is a
|
||||
//! gradient when a plane fits each of its colour channels with all three sloping the same way, the
|
||||
//! colours along that way are a few stops with even ramps between them, and painting those stops
|
||||
//! back gives the area's own pixels. Anything else (a flat area, a photo's sky lit two ways, two
|
||||
//! flats with a soft edge between them) is left to be traced as before.
|
||||
|
||||
use crate::Raster;
|
||||
use crate::quantize::TRANSPARENT;
|
||||
use kurbo::{Point, Vec2};
|
||||
|
||||
/// The largest step (levels of a channel) between neighbouring pixels of one smooth area.
|
||||
const STEP: i32 = 8;
|
||||
/// The fewest pixels a gradient has, and the least length (px) along its direction.
|
||||
const MIN_PIXELS: usize = 400;
|
||||
const MIN_LENGTH: f64 = 16.0;
|
||||
/// The least change (levels of a channel) from one stop to another: less is a flat colour.
|
||||
const MIN_CHANGE: f64 = 24.0;
|
||||
/// The most stops, and the least distance between two (as a share of the length): closer stops are
|
||||
/// an edge, not a ramp.
|
||||
const MAX_STOPS: usize = 6;
|
||||
const MIN_SPACING: f64 = 0.08;
|
||||
/// How far (levels) the stops may be from the colours along the gradient.
|
||||
const STOP_TOLERANCE: f64 = 2.0;
|
||||
|
||||
/// A linear gradient a traced path is filled with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TracedGradient {
|
||||
/// The gradient's line, in pixel coordinates: the first stop's colour is at `start` and before
|
||||
/// it, the last one's at `end` and beyond.
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
/// The stops: how far along the line (0 to 1, ascending, the first 0 and the last 1) and the colour.
|
||||
pub stops: Vec<(f64, [u8; 3])>,
|
||||
}
|
||||
|
||||
impl TracedGradient {
|
||||
/// The gradient's colour at `p`.
|
||||
pub fn color_at(&self, p: Point) -> [f64; 3] {
|
||||
let d = self.end - self.start;
|
||||
let t = if d.hypot2() > 0.0 { ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let color = |i: usize| self.stops.get(i).map_or([0.0; 3], |s| s.1.map(f64::from));
|
||||
let after = self.stops.iter().position(|s| s.0 >= t).unwrap_or(self.stops.len().saturating_sub(1));
|
||||
let Some(before) = after.checked_sub(1) else { return color(after) };
|
||||
let (ta, tb) = (self.stops.get(before).map_or(0.0, |s| s.0), self.stops.get(after).map_or(1.0, |s| s.0));
|
||||
let k = if tb > ta { (t - ta) / (tb - ta) } else { 1.0 };
|
||||
let (a, b) = (color(before), color(after));
|
||||
[0, 1, 2].map(|c| a[c] + (b[c] - a[c]) * k)
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradients of `img`: for each pixel the gradient it is part of (an index into the list, or
|
||||
/// `u32::MAX`), and the list.
|
||||
pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let px = |i: usize| img.rgba.get(i * 4..i * 4 + 4).filter(|p| p[3] >= 128).map(|p| [i32::from(p[0]), i32::from(p[1]), i32::from(p[2])]);
|
||||
let near = |a: [i32; 3], b: Option<[i32; 3]>| b.is_some_and(|b| (0..3).all(|k| (a[k] - b[k]).abs() <= STEP));
|
||||
// Smooth: an opaque pixel that differs little from each neighbour it has.
|
||||
let smooth: Vec<bool> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
px(i).is_some_and(|c| {
|
||||
(x == 0 || near(c, px(i - 1)))
|
||||
&& (x + 1 == w || near(c, px(i + 1)))
|
||||
&& (y == 0 || near(c, px(i - w)))
|
||||
&& (y + 1 == h || near(c, px(i + w)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Sloping: smooth, and some pixel a little way off (a 32nd of the image: a gradient across a
|
||||
// large image changes by less than a level from one pixel to the next) is another colour.
|
||||
// Flat colour is no part of a gradient, and left out it does not join a gradient to the flat
|
||||
// area beside it where the two are nearly one colour.
|
||||
let far = (w.min(h) / 32).clamp(4, 64) as i64;
|
||||
let around = [
|
||||
(far, 0),
|
||||
(-far, 0),
|
||||
(0, far),
|
||||
(0, -far),
|
||||
(far * 3 / 4, far * 3 / 4),
|
||||
(far * 3 / 4, -far * 3 / 4),
|
||||
(-far * 3 / 4, far * 3 / 4),
|
||||
(-far * 3 / 4, -far * 3 / 4),
|
||||
];
|
||||
let sloping: Vec<bool> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
let differs = |(dx, dy): (i64, i64)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
let other = (nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| px(ny as usize * w + nx as usize)).flatten();
|
||||
px(i).zip(other).is_some_and(|(a, b)| (0..3).any(|k| (a[k] - b[k]).abs() >= 2))
|
||||
};
|
||||
smooth.get(i).copied().unwrap_or(false) && around.into_iter().any(differs)
|
||||
})
|
||||
.collect();
|
||||
drop(smooth);
|
||||
let smooth = sloping;
|
||||
let mut of = vec![u32::MAX; w * h];
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut depth = vec![0u16; w * h];
|
||||
let mut found = vec![];
|
||||
let (mut members, mut stack) = (vec![], vec![]);
|
||||
for seed in 0..w * h {
|
||||
if seen.get(seed).copied().unwrap_or(true) || !smooth.get(seed).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// The connected smooth pixels from here.
|
||||
members.clear();
|
||||
stack.push(seed);
|
||||
if let Some(s) = seen.get_mut(seed) {
|
||||
*s = true;
|
||||
}
|
||||
while let Some(i) = stack.pop() {
|
||||
members.push(i);
|
||||
let (x, y) = (i % w, i / w);
|
||||
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
|
||||
if smooth.get(j).copied().unwrap_or(false) && !seen.get(j).copied().unwrap_or(true) {
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
if members.len() < MIN_PIXELS || found.len() >= 1000 {
|
||||
continue;
|
||||
}
|
||||
// The gradient is read from the area's inside, where there is one: the pixels at its rim
|
||||
// may be a soft edge to the next area, joined to this one where the two are alike.
|
||||
// (`depth`: how many pixels in from the rim each of the area's pixels is, counted inwards
|
||||
// from the pixels that have a neighbour outside it, as far as matters.)
|
||||
let rim = far as usize + 2;
|
||||
let unknown = u16::MAX;
|
||||
members.iter().for_each(|&i| depth[i] = unknown);
|
||||
let beside = |i: usize| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
|
||||
};
|
||||
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i).any(|j| depth.get(j).copied() == Some(0))).collect();
|
||||
front.iter().for_each(|&i| depth[i] = 1);
|
||||
for d in 2..=rim as u16 {
|
||||
let mut next = vec![];
|
||||
for &i in &front {
|
||||
for j in beside(i) {
|
||||
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
|
||||
*slot = d;
|
||||
next.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
front = next;
|
||||
}
|
||||
let inside: Vec<usize> = members.iter().copied().filter(|&i| depth.get(i).copied() == Some(unknown)).collect();
|
||||
let fitted = fit(img, w, if inside.len() >= MIN_PIXELS { &inside } else { &members }, &members, rim);
|
||||
members.iter().for_each(|&i| depth[i] = 0);
|
||||
if let Some((g, part)) = fitted {
|
||||
for &i in &part {
|
||||
if let Some(o) = of.get_mut(i) {
|
||||
*o = found.len() as u32;
|
||||
}
|
||||
}
|
||||
found.push(g);
|
||||
}
|
||||
}
|
||||
(of, found)
|
||||
}
|
||||
|
||||
/// The linear gradient the pixels `members` of `img` (`w` wide) are, if they are one, and those of
|
||||
/// `all` (the whole area `members` are the inside of, `rim` pixels in) that have its colours.
|
||||
fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) -> Option<(TracedGradient, Vec<usize>)> {
|
||||
let n = members.len() as f64;
|
||||
let at = |i: usize| Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
|
||||
let mut middle = Vec2::ZERO;
|
||||
let mut mean = [0.0; 3];
|
||||
for &i in members {
|
||||
middle += at(i).to_vec2();
|
||||
let c = rgb(i);
|
||||
(0..3).for_each(|k| mean[k] += c[k]);
|
||||
}
|
||||
let middle = (middle / n).to_point();
|
||||
let mean = mean.map(|v| v / n);
|
||||
// A plane through each channel, by least squares about the middle.
|
||||
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
|
||||
let (mut scx, mut scy) = ([0.0; 3], [0.0; 3]);
|
||||
for &i in members {
|
||||
let (d, c) = (at(i) - middle, rgb(i));
|
||||
sxx += d.x * d.x;
|
||||
sxy += d.x * d.y;
|
||||
syy += d.y * d.y;
|
||||
for k in 0..3 {
|
||||
scx[k] += d.x * (c[k] - mean[k]);
|
||||
scy[k] += d.y * (c[k] - mean[k]);
|
||||
}
|
||||
}
|
||||
let det = sxx * syy - sxy * sxy;
|
||||
if det <= 1e-6 * sxx * syy || !det.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Each channel's slope, and the one direction they share: the longer axis of the three
|
||||
// together (the shorter is what they do not share, and must be next to nothing).
|
||||
let slope = [0, 1, 2].map(|k| Vec2::new((scx[k] * syy - scy[k] * sxy) / det, (scy[k] * sxx - scx[k] * sxy) / det));
|
||||
let (mxx, mxy, myy) = slope.iter().fold((0.0, 0.0, 0.0), |m, s| (m.0 + s.x * s.x, m.1 + s.x * s.y, m.2 + s.y * s.y));
|
||||
let spread = ((mxx - myy) / 2.0).hypot(mxy);
|
||||
let (major, minor) = ((mxx + myy) / 2.0 + spread, (mxx + myy) / 2.0 - spread);
|
||||
if major <= 0.0 || minor > 0.03 * major {
|
||||
return None;
|
||||
}
|
||||
let angle = 0.5 * (2.0 * mxy).atan2(mxx - myy);
|
||||
let dir = Vec2::new(angle.cos(), angle.sin());
|
||||
// The colours along it, a pixel's width at a time.
|
||||
let along = |i: usize| (at(i) - middle).dot(dir);
|
||||
let (t0, t1) = members.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, &i| (m.0.min(along(i) - 0.5), m.1.max(along(i) + 0.5)));
|
||||
let length = t1 - t0;
|
||||
if !(MIN_LENGTH..1e6).contains(&length) {
|
||||
return None;
|
||||
}
|
||||
let bins = length.ceil() as usize;
|
||||
// (Each step's mean colour, at the mean place of its pixels: the few pixels at the tip of a
|
||||
// round area are not at the middle of their step.)
|
||||
let mut sums = vec![([0.0; 3], 0.0f64, 0.0f64); bins];
|
||||
for &i in members {
|
||||
let t = along(i) - t0;
|
||||
if let Some(b) = sums.get_mut((t as usize).min(bins - 1)) {
|
||||
let c = rgb(i);
|
||||
(0..3).for_each(|k| b.0[k] += c[k]);
|
||||
b.1 += 1.0;
|
||||
b.2 += t;
|
||||
}
|
||||
}
|
||||
let curve: Vec<(f64, [f64; 3])> = sums.iter().filter(|b| b.1 > 0.0).map(|b| (b.2 / b.1 / length, b.0.map(|v| v / b.1))).collect();
|
||||
// The fewest stops whose ramps pass within tolerance of every colour along the way: the two
|
||||
// ends, then again and again the colour farthest from the ramp it lies on.
|
||||
let mut keep = vec![0, curve.len().checked_sub(1)?];
|
||||
loop {
|
||||
let mut worst = (STOP_TOLERANCE, 0);
|
||||
for pair in keep.windows(2) {
|
||||
let (Some(&(ta, a)), Some(&(tb, b))) = (curve.get(pair[0]), curve.get(pair[1])) else { return None };
|
||||
for (j, &(t, c)) in curve.iter().enumerate().take(pair[1]).skip(pair[0] + 1) {
|
||||
let k = (t - ta) / (tb - ta);
|
||||
let off = (0..3).map(|ch| (c[ch] - (a[ch] + (b[ch] - a[ch]) * k)).abs()).fold(0.0, f64::max);
|
||||
if off > worst.0 {
|
||||
worst = (off, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst.1 == 0 {
|
||||
break;
|
||||
}
|
||||
// (More than are let stand: those close together become one below.)
|
||||
if keep.len() >= 4 * MAX_STOPS {
|
||||
return None;
|
||||
}
|
||||
keep.push(worst.1);
|
||||
keep.sort_unstable();
|
||||
}
|
||||
// Two stops close together are one stop where the ramp turns (a turn is not sharp to the
|
||||
// pixel, least of all in a blurred image), unless their colours are far apart: that is an
|
||||
// edge, not a ramp.
|
||||
let mut stops: Vec<(f64, [f64; 3])> = vec![];
|
||||
for &(t, c) in keep.iter().filter_map(|&j| curve.get(j)) {
|
||||
match stops.last_mut() {
|
||||
Some(last) if t - last.0 < MIN_SPACING => {
|
||||
if (0..3).any(|k| (c[k] - last.1[k]).abs() >= MIN_CHANGE) {
|
||||
return None;
|
||||
}
|
||||
*last = ((last.0 + t) / 2.0, [0, 1, 2].map(|k| (last.1[k] + c[k]) / 2.0));
|
||||
}
|
||||
_ => stops.push((t, c)),
|
||||
}
|
||||
}
|
||||
let (first, last) = (stops.first()?.0, stops.last()?.0);
|
||||
// A change of colour, not one colour.
|
||||
let change =
|
||||
stops.iter().flat_map(|a| stops.iter().map(move |b| (0..3).map(|k| (a.1[k] - b.1[k]).abs()).fold(0.0, f64::max))).fold(0.0, f64::max);
|
||||
// And ramps most of the way: two flat colours with a soft edge between them are no gradient.
|
||||
let level: f64 = stops.windows(2).filter(|s| (0..3).all(|k| (s[1].1[k] - s[0].1[k]).abs() < 4.0)).map(|s| s[1].0 - s[0].0).sum();
|
||||
if !(2..=MAX_STOPS).contains(&stops.len()) || last - first < MIN_SPACING || change < MIN_CHANGE || level > 0.3 * (last - first) {
|
||||
return None;
|
||||
}
|
||||
let gradient = TracedGradient {
|
||||
start: middle + dir * (t0 + first * length),
|
||||
end: middle + dir * (t0 + last * length),
|
||||
stops: stops.iter().map(|&(t, c)| ((t - first) / (last - first), c.map(|v| v.round().clamp(0.0, 255.0) as u8))).collect(),
|
||||
};
|
||||
// Carried on to the ends of the whole area, each end ramp going on as it goes.
|
||||
let d = gradient.end - gradient.start;
|
||||
let place = |i: usize| (at(i) - gradient.start).dot(d) / d.hypot2();
|
||||
let reach = rim as f64 * 1.5 / d.hypot();
|
||||
let (u0, u1) = all.iter().fold((0.0f64, 1.0f64), |m, &i| (m.0.min(place(i)), m.1.max(place(i))));
|
||||
let (u0, u1) = (u0.max(-reach), u1.min(1.0 + reach));
|
||||
let ramp = |a: (f64, [u8; 3]), b: (f64, [u8; 3]), u: f64| {
|
||||
[0, 1, 2].map(|k| (f64::from(a.1[k]) + (f64::from(b.1[k]) - f64::from(a.1[k])) * (u - a.0) / (b.0 - a.0)).round().clamp(0.0, 255.0) as u8)
|
||||
};
|
||||
let mut stops = gradient.stops.clone();
|
||||
if let (&[a, b, ..], &[.., y, z]) = (stops.as_slice(), stops.as_slice()) {
|
||||
if let Some(first) = stops.first_mut() {
|
||||
*first = (u0, ramp(a, b, u0));
|
||||
}
|
||||
if let Some(last) = stops.last_mut() {
|
||||
*last = (u1, ramp(y, z, u1));
|
||||
}
|
||||
}
|
||||
let gradient = TracedGradient {
|
||||
start: gradient.start + d * u0,
|
||||
end: gradient.start + d * u1,
|
||||
stops: stops.into_iter().map(|(u, c)| ((u - u0) / (u1 - u0), c)).collect(),
|
||||
};
|
||||
// Painted back, it is the area: most pixels of its inside within a few levels, and all that
|
||||
// are (the rest: noise, a soft rim, the flat colour just beyond it) on average nearer still.
|
||||
let off = |i: usize| {
|
||||
let (c, g) = (rgb(i), gradient.color_at(at(i)));
|
||||
(0..3).map(|k| (c[k] - g[k]).abs()).fold(0.0, f64::max)
|
||||
};
|
||||
let part: Vec<usize> = all.iter().copied().filter(|&i| off(i) <= 6.0).collect();
|
||||
let mean = part.iter().map(|&i| off(i)).sum::<f64>() / part.len().max(1) as f64;
|
||||
let inside = members.iter().filter(|&&i| off(i) <= 6.0).count();
|
||||
(part.len() >= MIN_PIXELS && inside * 4 >= members.len() * 3 && mean <= 3.5).then_some((gradient, part))
|
||||
}
|
||||
|
||||
/// Settles the pixels along the edge of each gradient (they are part gradient and part what lies
|
||||
/// beyond, and no part of the smooth area the gradient was found in): each joins the gradient
|
||||
/// (`of`) when more than half of it is the gradient's colour there, and else takes the label of
|
||||
/// what lies beyond. `labels` are the image's palette labels, the gradients' pixels not among them.
|
||||
pub(crate) fn settle_edges(img: &Raster, of: &mut [u32], gradients: &[TracedGradient], labels: &mut [u16]) {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 4).filter(|p| p[3] >= 128).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
|
||||
// Twice: a soft edge is two pixels wide.
|
||||
for _ in 0..2 {
|
||||
let mut joins: Vec<(usize, u32)> = vec![];
|
||||
let mut takes: Vec<(usize, u16)> = vec![];
|
||||
for i in 0..w * h {
|
||||
if of.get(i).copied() != Some(u32::MAX) {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = rgb(i) else { continue };
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
let index = |x: i64, y: i64| (x >= 0 && y >= 0 && x < w as i64 && y < h as i64).then(|| y as usize * w + x as usize);
|
||||
// The gradient on one side, and what lies two pixels out on the other.
|
||||
let Some((g, dx, dy)) = [(1, 0), (-1, 0), (0, 1), (0, -1)].into_iter().find_map(|(dx, dy)| {
|
||||
let g = index(x + dx, y + dy).and_then(|j| of.get(j).copied()).filter(|&g| g != u32::MAX)?;
|
||||
Some((g, dx, dy))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let Some(gradient) = gradients.get(g as usize) else { continue };
|
||||
let own = gradient.color_at(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let off = (0..3).map(|k| (c[k] - own[k]).abs()).fold(0.0, f64::max);
|
||||
let beyond = index(x - 2 * dx, y - 2 * dy).filter(|&j| of.get(j).copied() == Some(u32::MAX));
|
||||
let other = beyond.and_then(|j| Some((rgb(j)?, labels.get(j).copied()?)));
|
||||
match other {
|
||||
Some((o, label)) if (0..3).map(|k| (o[k] - own[k]).abs()).fold(0.0, f64::max) > 16.0 => {
|
||||
// How much of the pixel is the other colour.
|
||||
let d = [0, 1, 2].map(|k| o[k] - own[k]);
|
||||
let share = (0..3).map(|k| (c[k] - own[k]) * d[k]).sum::<f64>() / d.iter().map(|v| v * v).sum::<f64>();
|
||||
if share < 0.5 {
|
||||
joins.push((i, g));
|
||||
} else if label != TRANSPARENT {
|
||||
takes.push((i, label));
|
||||
}
|
||||
}
|
||||
// Gradient beyond too, or nothing to tell it from: part of it if it has its colour.
|
||||
_ if off <= 16.0 => joins.push((i, g)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (i, g) in joins {
|
||||
if let Some(o) = of.get_mut(i) {
|
||||
*o = g;
|
||||
}
|
||||
}
|
||||
for (i, l) in takes {
|
||||
if let Some(slot) = labels.get_mut(i) {
|
||||
*slot = l;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ramp(w: u32, h: u32, f: impl Fn(f64, f64) -> [f64; 3]) -> Raster {
|
||||
Raster::from_fn(w, h, |x, y| {
|
||||
let c = f(f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
[c[0].round() as u8, c[1].round() as u8, c[2].round() as u8, 255]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_ramp_is_one_gradient_with_its_direction_and_ends() {
|
||||
// Dark blue to yellow, 30° down from level.
|
||||
let dir = Vec2::new(30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let reach = Vec2::new(120.0, 80.0).dot(dir);
|
||||
let img = ramp(120, 80, |x, y| {
|
||||
let t = Vec2::new(x, y).dot(dir) / reach;
|
||||
[20.0 + 220.0 * t, 40.0 + 180.0 * t, 160.0 - 140.0 * t]
|
||||
});
|
||||
let (of, found) = detect(&img);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(of.iter().all(|&g| g == 0));
|
||||
let g = &found[0];
|
||||
let d = g.end - g.start;
|
||||
assert!((d.atan2().to_degrees() - 30.0).abs() < 1.0, "{d:?}");
|
||||
assert_eq!(g.stops.len(), 2);
|
||||
for (stop, truth) in g.stops.iter().zip([[20.0, 40.0, 160.0], [240.0, 220.0, 20.0]]) {
|
||||
assert!((0..3).all(|k| (f64::from(stop.1[k]) - truth[k]).abs() < 6.0), "{:?}", g.stops);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_colour_soft_edges_and_two_way_shading_are_no_gradient() {
|
||||
// One colour.
|
||||
assert!(detect(&ramp(64, 64, |_, _| [200.0, 30.0, 30.0])).1.is_empty());
|
||||
// Two flat colours with a soft edge between them.
|
||||
let soft = ramp(96, 64, |x, _| [100.0 + 30.0 / (1.0 + (-(x - 48.0)).exp()); 3]);
|
||||
assert!(detect(&soft).1.is_empty());
|
||||
// Red changing across and green changing down.
|
||||
assert!(detect(&ramp(96, 96, |x, y| [40.0 + 2.0 * x, 40.0 + 2.0 * y, 90.0])).1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_stops_are_three_stops() {
|
||||
let img = ramp(200, 40, |x, _| {
|
||||
let t = x / 200.0;
|
||||
if t < 0.5 { [255.0 - 400.0 * t, 40.0, 40.0 + 300.0 * t] } else { [55.0 + 300.0 * (t - 0.5), 40.0 + 400.0 * (t - 0.5), 190.0] }
|
||||
});
|
||||
let (_, found) = detect(&img);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].stops.len(), 3, "{:?}", found[0].stops);
|
||||
assert!((found[0].stops[1].0 - 0.5).abs() < 0.03);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ mod tests {
|
||||
|
||||
fn disc(c: (f64, f64), r: f64, color: u8) -> TracedPath {
|
||||
let path = PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Circle::new(c, r), 0.01));
|
||||
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None }
|
||||
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+49
-4
@@ -45,6 +45,7 @@
|
||||
mod centerline;
|
||||
mod contour;
|
||||
mod fit;
|
||||
mod gradient;
|
||||
mod group;
|
||||
mod logo;
|
||||
mod mosaic;
|
||||
@@ -59,6 +60,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
pub use contour::{Component, Loop, trace_mask};
|
||||
pub use gradient::TracedGradient;
|
||||
pub use mosaic::mosaic;
|
||||
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
|
||||
pub use shapes::TracedShape;
|
||||
@@ -287,6 +289,9 @@ pub struct TraceParams {
|
||||
pub shapes: bool,
|
||||
/// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]).
|
||||
pub auto_grouping: bool,
|
||||
/// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction
|
||||
/// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour.
|
||||
pub gradients: bool,
|
||||
/// Create: trace areas as filled shapes.
|
||||
pub fills: bool,
|
||||
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
|
||||
@@ -319,6 +324,7 @@ impl Default for TraceParams {
|
||||
snap_curves_to_lines: false,
|
||||
shapes: false,
|
||||
auto_grouping: false,
|
||||
gradients: false,
|
||||
fills: true,
|
||||
strokes: false,
|
||||
stroke_width: 10.0,
|
||||
@@ -438,6 +444,8 @@ pub struct TracedPath {
|
||||
pub stroke: Option<f64>,
|
||||
/// Shapes: the shape the path is, exactly (`path` is that shape's outline).
|
||||
pub shape: Option<TracedShape>,
|
||||
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
|
||||
pub gradient: Option<TracedGradient>,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
@@ -506,7 +514,32 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
|
||||
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
|
||||
let img = smoothed.as_ref().unwrap_or(img);
|
||||
let mut q = quantize(img, params);
|
||||
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
|
||||
let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(img)).filter(|f| !f.1.is_empty());
|
||||
let without = found.as_ref().map(|(of, _)| {
|
||||
let mut rest = img.clone();
|
||||
for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) {
|
||||
if g != u32::MAX {
|
||||
p[3] = 0;
|
||||
}
|
||||
}
|
||||
rest
|
||||
});
|
||||
let mut q = quantize(without.as_ref().unwrap_or(img), params);
|
||||
drop(without);
|
||||
// Each gradient is a palette entry of its own (its colour: the gradient's middle).
|
||||
let mut gradients: Vec<Option<TracedGradient>> = vec![None; q.palette.len()];
|
||||
if let Some((mut of, found)) = found {
|
||||
gradient::settle_edges(img, &mut of, &found, &mut q.labels);
|
||||
let first = q.palette.len();
|
||||
for (l, &g) in q.labels.iter_mut().zip(&of) {
|
||||
if g != u32::MAX {
|
||||
*l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT);
|
||||
}
|
||||
}
|
||||
q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8)));
|
||||
gradients.extend(found.into_iter().map(Some));
|
||||
}
|
||||
denoise(&mut q.labels, w, h, params.noise as usize);
|
||||
// Pixel count per palette entry, and the box its pixels lie in.
|
||||
let mut counts = vec![0usize; q.palette.len()];
|
||||
@@ -547,6 +580,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
img,
|
||||
ignore,
|
||||
boxes,
|
||||
gradients: &gradients,
|
||||
bw_tones: bw_tones(img, &q),
|
||||
style: params.outline_style(),
|
||||
w,
|
||||
@@ -609,6 +643,8 @@ struct Layers<'a> {
|
||||
ignore: Option<[u8; 3]>,
|
||||
/// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded.
|
||||
boxes: Vec<Option<(usize, usize, usize, usize)>>,
|
||||
/// Gradients: the gradient each palette entry is, if it is one.
|
||||
gradients: &'a [Option<TracedGradient>],
|
||||
/// Black and White: the luminance of white and of black (see `bw_tones`).
|
||||
bw_tones: [f64; 2],
|
||||
/// How outlines and centre lines are fitted.
|
||||
@@ -706,8 +742,15 @@ impl Layers<'_> {
|
||||
let tone = |l: u16| [if l == 1 { black } else { white }; 3];
|
||||
(tone(la), tone(lb))
|
||||
} else {
|
||||
let (Some(ca), Some(cb)) = (self.q.palette.get(la as usize), self.q.palette.get(lb as usize)) else { return 0.0 };
|
||||
(ca.map(f64::from), cb.map(f64::from))
|
||||
// A side's own colour: its palette entry's, or its gradient's colour here.
|
||||
let own = |l: u16, k: i32| {
|
||||
let flat = self.q.palette.get(l as usize)?.map(f64::from);
|
||||
let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) };
|
||||
let i = c.at(k)?;
|
||||
Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5)))
|
||||
};
|
||||
let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 };
|
||||
(ca, cb)
|
||||
};
|
||||
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
@@ -808,7 +851,9 @@ impl Layers<'_> {
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
return false;
|
||||
}
|
||||
paths.push(TracedPath { path, color, pixels, stroke, shape });
|
||||
// (A gradient fills; a stroked line of it is its middle colour.)
|
||||
let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none());
|
||||
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient });
|
||||
true
|
||||
};
|
||||
let mut comps = trace_mask(&mask, bw, bh);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -484,6 +492,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
pixels: (s.area * (factor * factor) as f64) as usize,
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -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<_>>());
|
||||
}
|
||||
@@ -1250,3 +1250,18 @@ fn a_hard_edged_circle_is_a_circle() {
|
||||
let [TracedPath { shape: Some(TracedShape::Ellipse { center, rx, ry, .. }), .. }] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
|
||||
assert!(rx == ry && (rx - 30.0).abs() < 0.3 && center.distance(Point::new(50.0, 50.0)) < 0.1, "{center:?} {rx} {ry}");
|
||||
}
|
||||
|
||||
/// Levelling moves no end far: a long line two degrees off level stays where the image has it.
|
||||
#[test]
|
||||
fn a_long_line_two_degrees_askew_is_not_levelled() {
|
||||
let slope = 2f64.to_radians().tan();
|
||||
let img = Raster::from_fn(240, 40, |x, y| {
|
||||
let top = 12.0 + slope * (f64::from(x) - 20.0);
|
||||
let inside = (20..220).contains(&x) && (f64::from(y) + 0.5 - top - 4.0).abs() <= 4.0;
|
||||
if inside { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
|
||||
});
|
||||
let res = trace(&img, &TraceParams { ignore_white: true, snap_curves_to_lines: true, ..Default::default() });
|
||||
let b = res.paths[0].path.bounds().unwrap();
|
||||
// 200 px at 2° rises 7 px: levelled, the bar would be 8 px tall, not 15.
|
||||
assert!(b.height() > 13.0, "{b:?}");
|
||||
}
|
||||
@@ -24,9 +24,11 @@ pub enum Class {
|
||||
Flat,
|
||||
/// Continuous tone: a gradient and a photo-like image.
|
||||
Tone,
|
||||
/// Linear gradients whose line and stops are known: one over the whole image, one in a disc.
|
||||
Ramp,
|
||||
}
|
||||
|
||||
pub const CLASSES: [Class; 7] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone];
|
||||
pub const CLASSES: [Class; 8] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp];
|
||||
|
||||
impl Class {
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -38,6 +40,7 @@ impl Class {
|
||||
Class::Plain => "plain shapes",
|
||||
Class::Flat => "flat colour art",
|
||||
Class::Tone => "gradient, photo",
|
||||
Class::Ramp => "linear gradients",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,9 +92,65 @@ pub struct Input {
|
||||
pub plain: Vec<TracedShape>,
|
||||
/// Its lines, where it is lines: each one's two ends and its width.
|
||||
pub strokes: Vec<(Point, Point, f64)>,
|
||||
/// The linear gradient it is, where it is one.
|
||||
pub ramp: Option<Ramp>,
|
||||
tone: Option<Tone>,
|
||||
}
|
||||
|
||||
/// A linear gradient: its line (256-unit coordinates) and its stops along it (0 to 1).
|
||||
#[derive(Clone)]
|
||||
pub struct Ramp {
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
pub stops: Vec<(f64, [f64; 3])>,
|
||||
}
|
||||
|
||||
impl Ramp {
|
||||
pub fn color_at(&self, p: Point) -> [f64; 3] {
|
||||
let d = self.end - self.start;
|
||||
let t = ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0);
|
||||
let after = self.stops.iter().position(|s| s.0 >= t).unwrap().max(1);
|
||||
let (a, b) = (self.stops[after - 1], self.stops[after]);
|
||||
let k = (t - a.0) / (b.0 - a.0);
|
||||
[0, 1, 2].map(|c| a.1[c] + (b.1[c] - a.1[c]) * k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dark blue to yellow across the whole image, 25° down from level.
|
||||
fn two_stop_ramp() -> Ramp {
|
||||
let dir = kurbo::Vec2::from_angle(25f64.to_radians());
|
||||
let reach = kurbo::Vec2::new(128.0, 128.0).dot(dir);
|
||||
let centre = Point::new(128.0, 128.0);
|
||||
Ramp { start: centre - dir * reach, end: centre + dir * reach, stops: vec![(0.0, [24.0, 40.0, 150.0]), (1.0, [250.0, 215.0, 60.0])] }
|
||||
}
|
||||
|
||||
/// Red through white to green across a disc, 110° from level.
|
||||
fn three_stop_ramp() -> Ramp {
|
||||
let dir = kurbo::Vec2::from_angle(110f64.to_radians());
|
||||
let centre = Point::new(128.0, 128.0);
|
||||
Ramp {
|
||||
start: centre - dir * DISC,
|
||||
end: centre + dir * DISC,
|
||||
stops: vec![(0.0, [200.0, 30.0, 40.0]), (0.4, [245.0, 240.0, 230.0]), (1.0, [20.0, 120.0, 70.0])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius of the disc the three-stop gradient fills.
|
||||
const DISC: f64 = 90.0;
|
||||
|
||||
fn two_stop(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
|
||||
two_stop_ramp().color_at(Point::new(u * 256.0, v * 256.0))
|
||||
}
|
||||
|
||||
fn three_stop(u: f64, v: f64, x: u32, _: u32) -> [f64; 3] {
|
||||
let p = Point::new(u * 256.0, v * 256.0);
|
||||
let inside = three_stop_ramp().color_at(p);
|
||||
// The disc's edge, anti-aliased: how much of the pixel (256 / size units wide) is in it.
|
||||
let pixel = 256.0 * u / (f64::from(x) + 0.5);
|
||||
let cover = (0.5 + (DISC - p.distance(Point::new(128.0, 128.0))) / pixel).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|c| 255.0 + (inside[c] - 255.0) * cover)
|
||||
}
|
||||
|
||||
const WHITE: [u8; 3] = [255, 255, 255];
|
||||
const INK: [u8; 3] = [0, 0, 0];
|
||||
|
||||
@@ -179,6 +238,7 @@ pub fn inputs() -> Vec<Input> {
|
||||
corners,
|
||||
strokes: vec![],
|
||||
plain: vec![],
|
||||
ramp: None,
|
||||
tone: None,
|
||||
};
|
||||
let tone = |name, f: Tone| Input {
|
||||
@@ -190,6 +250,7 @@ pub fn inputs() -> Vec<Input> {
|
||||
corners: vec![],
|
||||
strokes: vec![],
|
||||
plain: vec![],
|
||||
ramp: None,
|
||||
tone: Some(f),
|
||||
};
|
||||
let ink = |p: BezPath| vec![(p, INK)];
|
||||
@@ -301,6 +362,8 @@ pub fn inputs() -> Vec<Input> {
|
||||
art("four-colour badge", Class::Flat, multicolor, 20, points(&bar)),
|
||||
tone("gradient", gradient),
|
||||
tone("photo-like", photo),
|
||||
Input { class: Class::Ramp, ramp: Some(two_stop_ramp()), ..tone("two-stop gradient", two_stop) },
|
||||
Input { class: Class::Ramp, ramp: Some(three_stop_ramp()), ..tone("three-stop gradient in a disc", three_stop) },
|
||||
];
|
||||
if let Some(input) = all.iter_mut().find(|i| i.name == "plain shapes") {
|
||||
input.plain = plain;
|
||||
|
||||
@@ -74,6 +74,7 @@ fn target(preset: &str, input: &Input) -> bool {
|
||||
"3 Colors" => return input.name == "two touching halves",
|
||||
"6 Colors" | "16 Colors" | "Flat Logo" | "Auto Color" | "6 Colors with Auto Grouping" => &[Class::Flat],
|
||||
"Default with Shapes" | "6 Colors with Shapes" => &[Class::Plain],
|
||||
name if name.ends_with("Gradients") => &[Class::Ramp],
|
||||
"Sketched Art" => &[Class::Shape, Class::Lines],
|
||||
"Silhouettes" => &[Class::Shape],
|
||||
"Line Art" | "Outline" => &[Class::Lines],
|
||||
@@ -139,7 +140,14 @@ fn svg_trace(svg: &str) -> TraceResult {
|
||||
_ => panic!("unread transform {t}"),
|
||||
});
|
||||
}
|
||||
Some(TracedPath { path: vectorcraft_geom::PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: None, shape: None })
|
||||
Some(TracedPath {
|
||||
path: vectorcraft_geom::PathData::from_bezpath(&path),
|
||||
color: [0; 3],
|
||||
pixels: 0,
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
})
|
||||
});
|
||||
TraceResult { paths: paths.collect(), palette: vec![[0; 3]], ..Default::default() }
|
||||
}
|
||||
@@ -177,12 +185,12 @@ fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: u
|
||||
if x.is_finite() { format!("{:.digits$}", x + 0.0) } else { "all".into() }
|
||||
}
|
||||
|
||||
const HEADER: &str = "| Δ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 % |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
|
||||
const HEADER: &str = "| Δ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 |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
|
||||
|
||||
fn row(runs: &[&Run]) -> String {
|
||||
let m: Vec<&Metrics> = runs.iter().filter_map(|r| r.metrics.as_ref()).collect();
|
||||
if m.is_empty() {
|
||||
return format!("| refused |{}", " |".repeat(22));
|
||||
return format!("| refused |{}", " |".repeat(26));
|
||||
}
|
||||
let cells = [
|
||||
stat(&m, |m| m.delta, false, 2),
|
||||
@@ -208,6 +216,10 @@ fn row(runs: &[&Run]) -> String {
|
||||
stat(&m, |m| m.stroke_anchors, false, 1),
|
||||
stat(&m, |m| m.shapes, false, 0),
|
||||
stat(&m, |m| m.grouped, false, 0),
|
||||
stat(&m, |m| m.gradients, false, 1),
|
||||
stat(&m, |m| m.ramp_found, false, 0),
|
||||
stat(&m, |m| m.ramp_angle, true, 1),
|
||||
stat(&m, |m| m.ramp_off, true, 0),
|
||||
];
|
||||
format!("| {} |", cells.join(" | "))
|
||||
}
|
||||
@@ -243,6 +255,15 @@ fn report() -> String {
|
||||
] {
|
||||
presets.push((name, TraceParams { auto_grouping: true, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
// Gradients: on the inputs that are linear gradients, with the option and without it.
|
||||
for (name, base, gradients) in [
|
||||
("6 Colors, no Gradients", "6 Colors", false),
|
||||
("6 Colors with Gradients", "6 Colors", true),
|
||||
("High Fidelity Photo, no Gradients", "High Fidelity Photo", false),
|
||||
("High Fidelity Photo with Gradients", "High Fidelity Photo", true),
|
||||
] {
|
||||
presets.push((name, TraceParams { gradients, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
let inputs = inputs();
|
||||
let drawn = draw_all(&inputs);
|
||||
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew | Class::Plain);
|
||||
@@ -326,6 +347,15 @@ fn report() -> String {
|
||||
fn quality_report() {
|
||||
let md = report();
|
||||
println!("{md}");
|
||||
// Gradients finds none in art that is flat colour, however it is degraded.
|
||||
let inputs = inputs();
|
||||
for (name, _) in presets().iter().filter(|(_, p)| p.mode != vectorcraft_trace::Mode::BlackAndWhite && p.mode != vectorcraft_trace::Mode::Logo) {
|
||||
let params = TraceParams { gradients: true, ..vectorcraft_trace::preset(name).unwrap() };
|
||||
for (input, size, variant, img) in draw_all(&inputs).iter().filter(|d| !matches!(d.0.class, Class::Tone | Class::Ramp)) {
|
||||
let found = trace_within(img, ¶ms, MAX_ANCHORS).map_or(0, |res| res.paths.iter().filter(|p| p.gradient.is_some()).count());
|
||||
assert_eq!(found, 0, "{name} with Gradients on {} at {size} px, {variant:?}", input.name);
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::env::var("TRACE_QUALITY_REPORT") {
|
||||
std::fs::write(path, md).unwrap();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ use std::collections::HashMap;
|
||||
|
||||
use kurbo::{BezPath, ParamCurveArclen, Point, Shape, Stroke, StrokeOpts};
|
||||
use vectorcraft_geom::SubPath;
|
||||
use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedPath};
|
||||
use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedGradient, TracedPath};
|
||||
|
||||
use crate::inputs::{Class, Input, Variant};
|
||||
use crate::raster::{coverage_into, polylines, render};
|
||||
use crate::raster::{coverage_into, polylines, render_painted};
|
||||
|
||||
/// sRGB to Oklab (Björn Ottosson, "A perceptual color space for image processing", 2020).
|
||||
pub fn oklab(c: [u8; 3]) -> [f64; 3] {
|
||||
@@ -196,6 +196,13 @@ pub struct Metrics {
|
||||
pub shapes: f64,
|
||||
/// Auto Grouping: share (%) of the paths that lie inside another.
|
||||
pub grouped: f64,
|
||||
/// Gradients: paths filled with one; and where the input is a known gradient, whether one was
|
||||
/// found (%), the angle (°) between its line and the largest traced gradient's, and the largest
|
||||
/// difference (levels of a channel) between their colours along the true line.
|
||||
pub gradients: f64,
|
||||
pub ramp_found: f64,
|
||||
pub ramp_angle: f64,
|
||||
pub ramp_off: f64,
|
||||
/// Paths under 6 px of area.
|
||||
pub specks: f64,
|
||||
/// How far (px) the trace reaches outside the image: a stray curve.
|
||||
@@ -225,8 +232,33 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
let n = size as usize;
|
||||
let shapes: Vec<(BezPath, [u8; 3])> = res.paths.iter().map(|tp| (painted(tp), tp.color)).collect();
|
||||
// Painted as the result is grouped: each path, then those inside it.
|
||||
let drawn: Vec<(BezPath, [u8; 3])> = draw_order(res).into_iter().map(|i| shapes[i].clone()).collect();
|
||||
let (delta_mean, edge_delta) = fidelity(src, &render(&drawn, [255; 3], n, n));
|
||||
let drawn: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> =
|
||||
draw_order(res).into_iter().map(|i| (shapes[i].0.clone(), shapes[i].1, res.paths[i].gradient.as_ref())).collect();
|
||||
let (delta_mean, edge_delta) = fidelity(src, &render_painted(&drawn, [255; 3], n, n));
|
||||
// Gradients: how many paths have one; and the largest of them against the gradient the input
|
||||
// is: the angle between their lines, and the largest difference of a channel between their
|
||||
// colours at 17 points along the true line.
|
||||
let gradients = res.paths.iter().filter(|tp| tp.gradient.is_some()).count() as f64;
|
||||
let (mut ramp_found, mut ramp_angle, mut ramp_off) = (f64::NAN, f64::NAN, f64::NAN);
|
||||
if let Some(ramp) = &input.ramp {
|
||||
let k = f64::from(size) / 256.0;
|
||||
let found = res.paths.iter().filter(|tp| tp.gradient.is_some()).max_by_key(|tp| tp.pixels).and_then(|tp| tp.gradient.as_ref());
|
||||
ramp_found = if found.is_some() { 100.0 } else { 0.0 };
|
||||
if let Some(g) = found {
|
||||
let (truth, own) = (ramp.end - ramp.start, g.end - g.start);
|
||||
let between = (truth.atan2() - own.atan2()).to_degrees().rem_euclid(180.0);
|
||||
ramp_angle = between.min(180.0 - between);
|
||||
ramp_off = (0..=16)
|
||||
.map(|j| {
|
||||
let p = ramp.start + truth * (f64::from(j) / 16.0);
|
||||
// (The true colour as this variant shows it: a scan's are its own.)
|
||||
let a = variant.color(ramp.color_at(p).map(|v| v.round() as u8)).map(f64::from);
|
||||
let b = g.color_at((p.to_vec2() * k).to_point());
|
||||
(0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f64::max)
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
}
|
||||
}
|
||||
let grouped = if res.parents.is_empty() { f64::NAN } else { 100.0 * res.parents.iter().flatten().count() as f64 / res.paths.len().max(1) as f64 };
|
||||
|
||||
// The background's frame: a path as large as the image.
|
||||
@@ -239,7 +271,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
|
||||
let nan = f64::NAN;
|
||||
let (mut geo_mean, mut geo_max, mut miss_max, mut corners, mut kinks, mut slivers, mut sliver_area) = (nan, nan, nan, nan, nan, nan, nan);
|
||||
if input.class != Class::Tone {
|
||||
if !matches!(input.class, Class::Tone | Class::Ramp) {
|
||||
let truth_shapes = input.scaled(size);
|
||||
let truth = Outline::new(truth_shapes.iter().map(|(p, _)| p), None);
|
||||
let traced = Outline::new(shapes.iter().map(|(p, _)| p), Some(f64::from(size)));
|
||||
@@ -341,6 +373,10 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
|
||||
Metrics {
|
||||
grouped,
|
||||
gradients,
|
||||
ramp_found,
|
||||
ramp_angle,
|
||||
ramp_off,
|
||||
shapes: shapes_found,
|
||||
centre_off,
|
||||
width_off,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! from ground-truth vector shapes here, never loaded from files.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point};
|
||||
use vectorcraft_trace::Raster;
|
||||
use vectorcraft_trace::{Raster, TracedGradient};
|
||||
|
||||
/// The closed polylines of `path` (one per subpath), flattened within `tol`.
|
||||
pub fn polylines(path: &BezPath, tol: f64) -> Vec<Vec<Point>> {
|
||||
@@ -78,15 +78,25 @@ pub fn coverage(path: &BezPath, w: usize, h: usize) -> Vec<f32> {
|
||||
|
||||
/// Paints `scene` (bottom first) over `bg`, anti-aliased, the way a renderer shows a trace.
|
||||
pub fn render(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> Raster {
|
||||
let scene: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> = scene.iter().map(|(p, c)| (p.clone(), *c, None)).collect();
|
||||
render_painted(&scene, bg, w, h)
|
||||
}
|
||||
|
||||
/// [`render`], with the paths that have a gradient filled with it.
|
||||
pub fn render_painted(scene: &[(BezPath, [u8; 3], Option<&TracedGradient>)], bg: [u8; 3], w: usize, h: usize) -> Raster {
|
||||
let mut buf = vec![bg.map(f32::from); w * h];
|
||||
let mut cov = vec![0.0f32; w * h];
|
||||
for (path, color) in scene {
|
||||
for (path, color, gradient) in scene {
|
||||
let (r0, r1) = coverage_into(path, w, h, &mut cov);
|
||||
for i in r0 * w..r1 * w {
|
||||
let a = cov[i].min(1.0);
|
||||
if a > 0.0 {
|
||||
let color = match gradient {
|
||||
Some(g) => g.color_at(Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5)).map(|v| v as f32),
|
||||
None => color.map(f32::from),
|
||||
};
|
||||
for ch in 0..3 {
|
||||
buf[i][ch] += (f32::from(color[ch]) - buf[i][ch]) * a;
|
||||
buf[i][ch] += (color[ch] - buf[i][ch]) * a;
|
||||
}
|
||||
cov[i] = 0.0;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||||
let params = if params.is_null() { json!({}) } else { params };
|
||||
// A key the command's description doesn't name is an error, not a default (#1442).
|
||||
if let Err(e) = crate::menus::check_params(id, ¶ms) {
|
||||
if let Err(e) = crate::menus::check_params(&app.session, id, ¶ms) {
|
||||
return err(e);
|
||||
}
|
||||
wrap(app.run(id, params))
|
||||
|
||||
@@ -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}
|
||||
@@ -3740,6 +3793,7 @@ imageTrace Outline Obrys
|
||||
@msg No saved selections (Select → Save Selection…) Žádné uložené výběry (Výběr → Uložit výběr…)
|
||||
@msg No saved views (View → New View…) Žádná uložená zobrazení (Zobrazení → Nové zobrazení…)
|
||||
@msg Offset Path: select paths Odsadit cestu: vyberte cesty
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Odsadit cestu: po odsazení z vybraných cest nic nezbude (odsazení dovnitř větší než tvary)
|
||||
@msg Opened with {_1} note(s): {_2} Otevřeno, poznámky ({_1}): {_2}
|
||||
@msg Opened {url} Otevřeno {url}
|
||||
@msg Outline Stroke: no stroked paths selected Převést tah na obrys: nejsou vybrány žádné cesty s tahem
|
||||
@@ -4194,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}
|
||||
@@ -3341,6 +3395,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg No saved selections (Select → Save Selection…) Keine gespeicherten Auswahlen (Auswahl → Auswahl speichern…)
|
||||
@msg No saved views (View → New View…) Keine gespeicherten Ansichten (Ansicht → Neue Ansicht…)
|
||||
@msg Offset Path: select paths Pfad verschieben: Wählen Sie Pfade aus
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Pfad verschieben: Der Versatz lässt von den ausgewählten Pfaden nichts übrig (ein Versatz nach innen, der größer ist als die Formen)
|
||||
@msg Opened with {_1} note(s): {_2} Mit {_1} Hinweis(en) geöffnet: {_2}
|
||||
@msg Opened {url} {url} geöffnet
|
||||
@msg Outline Stroke: no stroked paths selected Konturlinie: keine Pfade mit Kontur ausgewählt
|
||||
@@ -3796,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}
|
||||
@@ -3441,6 +3495,7 @@ axis Both Ambos
|
||||
@msg No saved selections (Select → Save Selection…) No hay selecciones guardadas (Selección → Guardar selección…)
|
||||
@msg No saved views (View → New View…) No hay vistas guardadas (Ver → Nueva vista…)
|
||||
@msg Offset Path: select paths Desplazar trazado: selecciona trazados
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Desplazar trazado: el desplazamiento no deja nada de los trazados seleccionados (un desplazamiento hacia dentro mayor que las formas)
|
||||
@msg Opened with {_1} note(s): {_2} Se abrió con {_1} nota(s): {_2}
|
||||
@msg Opened {url} Se abrió {url}
|
||||
@msg Outline Stroke: no stroked paths selected Contornear trazo: no hay trazados con trazo seleccionados
|
||||
@@ -3896,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}
|
||||
@@ -3438,6 +3492,7 @@ axis Both Les deux
|
||||
@msg No saved selections (Select → Save Selection…) Aucune sélection enregistrée (Sélection → Enregistrer la sélection…)
|
||||
@msg No saved views (View → New View…) Aucune vue enregistrée (Affichage → Nouvelle vue…)
|
||||
@msg Offset Path: select paths Décalage : sélectionnez des tracés
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Décalage : le décalage ne laisse rien des tracés sélectionnés (un décalage vers l’intérieur plus grand que les formes)
|
||||
@msg Opened with {_1} note(s): {_2} Ouvert avec {_1} remarque(s) : {_2}
|
||||
@msg Opened {url} {url} ouvert
|
||||
@msg Outline Stroke: no stroked paths selected Vectoriser le contour : aucun tracé avec contour sélectionné
|
||||
@@ -3893,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}
|
||||
@@ -4339,6 +4393,7 @@ imageTrace Outline Körvonal
|
||||
@msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id}
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető
|
||||
@msg Offset Path: select paths Útvonal eltolása: jelölj ki útvonalakat
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Útvonal eltolása: az eltolás után semmi sem marad a kijelölt útvonalakból (a befelé eltolás nagyobb az alakzatoknál)
|
||||
@msg onto must name a floating panel az onto egy lebegő panelt kell megnevezzen
|
||||
@msg onto must name a panel floating in another group az onto egy másik csoportban lebegő panelt kell megnevezzen
|
||||
@msg Opened with {_1} note(s): {_2} Megnyitva; megjegyzések: {_1}: {_2}
|
||||
@@ -4597,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}
|
||||
@@ -3441,6 +3495,7 @@ axis Both Entrambi
|
||||
@msg No saved selections (Select → Save Selection…) Nessuna selezione salvata (Selezione → Salva selezione…)
|
||||
@msg No saved views (View → New View…) Nessuna vista salvata (Visualizza → Nuova vista…)
|
||||
@msg Offset Path: select paths Sposta tracciato: seleziona tracciati
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Sposta tracciato: lo spostamento non lascia nulla dei tracciati selezionati (uno spostamento verso l’interno maggiore delle forme)
|
||||
@msg Opened with {_1} note(s): {_2} Aperto con {_1} nota/e: {_2}
|
||||
@msg Opened {url} Aperto {url}
|
||||
@msg Outline Stroke: no stroked paths selected Contorna traccia: nessun tracciato con traccia selezionato
|
||||
@@ -3896,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 ブレンド軸を置き換え: ブレンドとパスを選択してください
|
||||
@@ -3598,6 +3652,7 @@ theme Appearance 外観
|
||||
@msg Divide Objects Below: select a path 背面のオブジェクトで型抜き: パスを選択してください
|
||||
@msg Divide Objects Below: select exactly one path 背面のオブジェクトで型抜き: パスを 1 つだけ選択してください
|
||||
@msg Offset Path: select paths パスのオフセット: パスを選択してください
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) パスのオフセット: オフセットすると選択したパスが何も残りません (内側へのオフセットがシェイプより大きい)
|
||||
@msg Outline Stroke: no stroked paths selected パスのアウトライン: 線のあるパスが選択されていません
|
||||
@msg Split Into Grid: select paths large enough for the grid グリッドに分割: グリッドが収まる大きさのパスを選択してください
|
||||
@msg empty cutter 型抜きに使うパスが空です
|
||||
@@ -3916,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 스파인 교체: 블렌드와 패스를 선택하세요
|
||||
@@ -3576,6 +3630,7 @@ theme Appearance 외관
|
||||
@msg Divide Objects Below: select a path 아래 개체 분할: 패스를 선택하세요
|
||||
@msg Divide Objects Below: select exactly one path 아래 개체 분할: 패스를 하나만 선택하세요
|
||||
@msg Offset Path: select paths 패스 오프셋: 패스를 선택하세요
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) 패스 오프셋: 오프셋을 적용하면 선택한 패스가 남지 않습니다(안쪽 오프셋이 모양보다 큼)
|
||||
@msg Outline Stroke: no stroked paths selected 선 윤곽선화: 선이 있는 패스가 선택되지 않았습니다
|
||||
@msg Split Into Grid: select paths large enough for the grid 격자로 분할: 격자에 충분한 크기의 패스를 선택하세요
|
||||
@msg empty cutter 빈 커터입니다
|
||||
@@ -3894,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}
|
||||
@@ -3606,6 +3659,7 @@ imageTrace Outline Kontur
|
||||
@msg No saved selections (Select → Save Selection…) Brak zapisanych zaznaczeń (Zaznacz → Zapisz zaznaczenie…)
|
||||
@msg No saved views (View → New View…) Brak zapisanych widoków (Widok → Nowy widok…)
|
||||
@msg Offset Path: select paths Odsuń ścieżkę: zaznacz ścieżki
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Odsuń ścieżkę: po odsunięciu nic nie zostaje z zaznaczonych ścieżek (odsunięcie do wewnątrz większe niż kształty)
|
||||
@msg Opened with {_1} note(s): {_2} Otwarto, uwagi ({_1}): {_2}
|
||||
@msg Opened {url} Otwarto {url}
|
||||
@msg Outline Stroke: no stroked paths selected Obrys konturu: nie zaznaczono ścieżek z obrysem
|
||||
@@ -4060,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}
|
||||
@@ -4520,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…
|
||||
@@ -3094,6 +3147,7 @@ axis Both Ambos
|
||||
@msg No saved selections (Select → Save Selection…) Nenhuma seleção salva (Selecionar → Salvar seleção…)
|
||||
@msg No saved views (View → New View…) Nenhuma vista salva (Exibir → Nova vista…)
|
||||
@msg Offset Path: select paths Deslocar traçado: selecione traçados
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Deslocar traçado: o deslocamento não deixa nada dos traçados selecionados (um deslocamento para dentro maior que as formas)
|
||||
@msg Opened with {_1} note(s): {_2} Aberto com {_1} observação(ões): {_2}
|
||||
@msg Opened {url} {url} aberto
|
||||
@msg Outline Stroke: no stroked paths selected Contornar traço: nenhum traçado com traço selecionado
|
||||
|
||||
|
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}
|
||||
@@ -3437,6 +3491,7 @@ axis Both Оба
|
||||
@msg No saved selections (Select → Save Selection…) Нет сохранённых выделений (Выделение → Сохранить выделение…)
|
||||
@msg No saved views (View → New View…) Нет сохранённых видов (Просмотр → Новый вид…)
|
||||
@msg Offset Path: select paths Смещение контура: выделите контуры
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Смещение контура: после смещения от выделенных контуров ничего не остаётся (смещение внутрь больше самих фигур)
|
||||
@msg Opened with {_1} note(s): {_2} Открыто с примечаниями ({_1}) : {_2}
|
||||
@msg Opened {url} Открыто {url}
|
||||
@msg Outline Stroke: no stroked paths selected Обводка контура: не выделены контуры с обводкой
|
||||
@@ -3892,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}
|
||||
@@ -3425,6 +3479,7 @@ axis Both Обидві
|
||||
@msg No saved selections (Select → Save Selection…) Немає збережених виділень (Виділення → Зберегти виділення…)
|
||||
@msg No saved views (View → New View…) Немає збережених видів (Перегляд → Новий вид…)
|
||||
@msg Offset Path: select paths Зміщення контуру: виділіть контури
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Зміщення контуру: після зміщення від виділених контурів нічого не лишається (зміщення всередину більше за самі фігури)
|
||||
@msg Opened with {_1} note(s): {_2} Відкрито із примітками ({_1}) : {_2}
|
||||
@msg Opened {url} Відкрито {url}
|
||||
@msg Outline Stroke: no stroked paths selected Обведення контуру: не виділено контури з обведенням
|
||||
@@ -3880,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}
|
||||
@@ -3968,6 +4022,7 @@ theme Appearance Giao diện
|
||||
@msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa
|
||||
@msg Offset Path: select paths Đường dẫn độ lệch: hãy chọn đường dẫn
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Đường dẫn độ lệch: độ lệch không để lại gì từ các đường dẫn đã chọn (độ lệch vào trong lớn hơn hình)
|
||||
@msg onto must name a floating panel onto phải đặt tên bảng đang thả nổi
|
||||
@msg onto must name a panel floating in another group onto phải đặt tên bảng đang thả nổi trong nhóm khác
|
||||
@msg Opened with {_1} note(s): {_2} Đã mở với {_1} ghi chú: {_2}
|
||||
@@ -4236,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;
|
||||
@@ -623,7 +625,7 @@ impl VectorcraftApp {
|
||||
// In debug builds the app's own calls are held to the params descriptions too (#1442): a
|
||||
// panel passing a key its command doesn't read shows up in the tests.
|
||||
if cfg!(debug_assertions) {
|
||||
menus::check_params(id, ¶ms)?;
|
||||
menus::check_params(&self.session, id, ¶ms)?;
|
||||
}
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
return r;
|
||||
|
||||
@@ -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…",
|
||||
@@ -905,11 +911,11 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
|
||||
/// `params` has only keys the params description of engine command `id` names, or the parameter
|
||||
/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their
|
||||
/// descriptions haven't been audited against what they read.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<(), String> {
|
||||
pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> {
|
||||
if UI_COMMANDS.iter().any(|c| c.0 == id) {
|
||||
return Ok(());
|
||||
}
|
||||
vectorcraft_engine::check_params(id, params).map_err(|e| e.to_string())
|
||||
session.check_params(id, params).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
@@ -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(
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user