Compare commits
130
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 | ||
|
|
9c42a2942a | ||
|
|
36286cc783 | ||
|
|
bc343414d8 | ||
|
|
7b0164e4e7 | ||
|
|
670946ef75 | ||
|
|
bdb9963c73 | ||
|
|
dc64aaa41b | ||
|
|
5366cf689e | ||
|
|
cca3a63e4a | ||
|
|
507c6e0e63 | ||
|
|
dfc1b846cc | ||
|
|
a169693817 | ||
|
|
db85d465c8 | ||
|
|
de8d5c96ab | ||
|
|
d44615753a | ||
|
|
e794c1ac63 | ||
|
|
0573d3cdfb | ||
|
|
9ca74ac011 | ||
|
|
f0f3cb4eb1 | ||
|
|
5fe916a425 | ||
|
|
8e7b9ba436 | ||
|
|
cbcee3bd54 | ||
|
|
0810a15f6e | ||
|
|
db7e568809 | ||
|
|
9d72686d95 | ||
|
|
d79fb145c2 | ||
|
|
6a16627de6 | ||
|
|
acc1c9a85d | ||
|
|
8fbf724cf6 | ||
|
|
02ed637c68 | ||
|
|
9d2b074d12 | ||
|
|
632ea2a9f3 | ||
|
|
523fd6e185 | ||
|
|
612082e26e | ||
|
|
fea316911d | ||
|
|
3af08505f8 | ||
|
|
2c01a42b35 | ||
|
|
45334df881 | ||
|
|
ee97225c19 | ||
|
|
6085197bf4 | ||
|
|
50911d8608 | ||
|
|
6939eff2f9 | ||
|
|
9652d1d6b2 | ||
|
|
79b5d73ca7 | ||
|
|
05fdd15b45 | ||
|
|
0a16b62a22 | ||
|
|
20f5174710 | ||
|
|
00195e8d28 | ||
|
|
05e321f4e0 | ||
|
|
85770d3afb | ||
|
|
421443b8ac | ||
|
|
e2a63a8baf | ||
|
|
44d170f059 | ||
|
|
fce90e3144 | ||
|
|
761cc2371a | ||
|
|
3752b3ec31 | ||
|
|
c2ed31dc4c | ||
|
|
d9e44350d2 | ||
|
|
0f4de5b98e | ||
|
|
a971b5b70e | ||
|
|
6beaa89be2 | ||
|
|
40b02f479b | ||
|
|
afda1cd846 | ||
|
|
a7c6644691 | ||
|
|
3a88ad0855 | ||
|
|
61cc3f6040 | ||
|
|
3239e8b97e | ||
|
|
48d5bd1aee | ||
|
|
5203f4f047 | ||
|
|
a072e29016 | ||
|
|
ba68fcfbfc | ||
|
|
c9fac95760 |
No files matched your search
Generated
+9
-1
@@ -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"
|
||||
@@ -4916,7 +4925,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1465,6 +1465,14 @@ impl Document {
|
||||
pub fn new_graphic_style_name(&self) -> String {
|
||||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||||
}
|
||||
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
|
||||
pub fn unique_symbol_name(&self, base: &str) -> String {
|
||||
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Distort & Transform effects.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::util::*;
|
||||
@@ -194,7 +195,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
map_nonlinear(path, pieces, f)
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -207,8 +211,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
for j in 0..=ridges {
|
||||
let t = j as f64 / (ridges + 1) as f64;
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -244,6 +244,62 @@ fn zigzag_ridge_count_and_amplitude() {
|
||||
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
// #1554: on a straight line every ridge is the same distance from the line and as far from
|
||||
// the next one, whether the points are corners or smooth.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
|
||||
for points in ["corner", "smooth"] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
|
||||
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(ps.len(), 6, "{points}");
|
||||
for (k, q) in ps.iter().enumerate() {
|
||||
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
|
||||
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
fn zigzag_on_a_circle_is_regular() {
|
||||
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
|
||||
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts.len(), 16);
|
||||
let start = pts[0].y.atan2(pts[0].x);
|
||||
let mut radii = vec![];
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
|
||||
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
|
||||
radii.push(q.to_vec2().hypot().round());
|
||||
}
|
||||
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
|
||||
}
|
||||
|
||||
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let c = curve.subpaths[0].segment(0);
|
||||
let len = c.arclen(1e-6);
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
|
||||
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
|
||||
assert_eq!(along.len(), 5);
|
||||
for (k, share) in along.iter().enumerate() {
|
||||
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_grows_and_shrinks() {
|
||||
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -526,14 +526,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
Ok((art, (w, h), c))
|
||||
}
|
||||
|
||||
fn unique_symbol_name(d: &Document, base: &str) -> String {
|
||||
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -587,7 +579,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
@@ -764,7 +756,7 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
|
||||
let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -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, snapCurvesToLines, 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)
|
||||
),
|
||||
@@ -703,6 +703,76 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
/// refused with advice instead (#525).
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
|
||||
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
|
||||
use vectorcraft_geom::Affine;
|
||||
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
|
||||
let mut live = match shape {
|
||||
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
|
||||
w: width,
|
||||
h: height,
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
|
||||
},
|
||||
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
|
||||
}
|
||||
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
|
||||
}
|
||||
};
|
||||
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
|
||||
let _ = live.transform(xf);
|
||||
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.
|
||||
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
|
||||
let n = nodes.len();
|
||||
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut roots = vec![];
|
||||
for i in 0..n {
|
||||
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
|
||||
Some(of) => of.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
// Outermost first, so that backwards each path comes after those inside it.
|
||||
let mut order = roots.clone();
|
||||
let mut at = 0;
|
||||
while let Some(&i) = order.get(at) {
|
||||
order.extend(inside.get(i).into_iter().flatten());
|
||||
at += 1;
|
||||
}
|
||||
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
|
||||
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
}
|
||||
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
|
||||
// and they are kept all the same.)
|
||||
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
|
||||
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
|
||||
}
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let mut params = trace_params(p)?;
|
||||
@@ -763,12 +833,20 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
hidden.visible = false;
|
||||
children.push(Arc::new(hidden));
|
||||
}
|
||||
let mut traced = Vec::with_capacity(res.paths.len());
|
||||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
// Shapes: a path the tracer found to be a shape is that live shape.
|
||||
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
|
||||
let shape = traced_shape(shape, xf);
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
// 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 {
|
||||
@@ -782,8 +860,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
.into(),
|
||||
None => fill_only(paint).into(),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
traced.push(n);
|
||||
}
|
||||
children.extend(grouped(d, traced, &res.parents));
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
|
||||
|
||||
@@ -489,8 +489,10 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected.
|
||||
if v && !s.doc()?.selection.guides.is_empty() {
|
||||
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
|
||||
// those go).
|
||||
let st = s.doc()?;
|
||||
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
|
||||
@@ -1048,17 +1048,46 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
|
||||
open.anchors.push(open.anchors[0]);
|
||||
m.push(false);
|
||||
open.closed = false;
|
||||
let mut res = smooth_open(&open.anchors, &m, tol);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol);
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
}
|
||||
return Some(SubPath::new(res, true));
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false))
|
||||
}
|
||||
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver
|
||||
/// is outside.
|
||||
fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> {
|
||||
const STEPS: u32 = 32;
|
||||
let step = 1.0 / f64::from(STEPS);
|
||||
// Back from the end in reach to the first sample outside, then halving to the crossing.
|
||||
let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?;
|
||||
let (mut lo, mut hi) = (outside, outside + step);
|
||||
for _ in 0..20 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if near(c.eval(mid)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
// A straight segment's parts stay straight, their handles on their ends.
|
||||
let straight = c.p1 == c.p0 && c.p2 == c.p3;
|
||||
let part = |range| {
|
||||
let s = c.subsegment(range);
|
||||
if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s }
|
||||
};
|
||||
(hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0)))
|
||||
}
|
||||
|
||||
/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments
|
||||
/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under
|
||||
/// it, and the rest of a long segment next to it stays as it was (#1512).
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec<Anchor> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -1079,8 +1108,27 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
|
||||
if hi - lo < 2 {
|
||||
continue;
|
||||
}
|
||||
let new = refit_run(&out[lo..=hi], tol);
|
||||
out.splice(lo..=hi, new);
|
||||
let mut piece = out[lo..=hi].to_vec();
|
||||
// The anchors outside the reach, kept with the part of their segment that is outside.
|
||||
let (mut head, mut tail) = (None, None);
|
||||
if hi > b
|
||||
&& let [.., inner, outer] = piece.as_mut_slice()
|
||||
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near)
|
||||
{
|
||||
inner.h_out = reach.p2;
|
||||
tail = Some(Anchor { h_in: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, reach.p1, far.p2);
|
||||
}
|
||||
if lo < a
|
||||
&& let [outer, inner, ..] = piece.as_mut_slice()
|
||||
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near)
|
||||
{
|
||||
inner.h_in = reach.p2;
|
||||
head = Some(Anchor { h_out: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let new = refit_run(&piece, tol);
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -132,7 +132,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
|
||||
cmd!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
None,
|
||||
"{dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::pathops::Beside;
|
||||
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Join",
|
||||
["Object", "Path"],
|
||||
Some("Cmd+J"),
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
|
||||
has_selection,
|
||||
join
|
||||
),
|
||||
@@ -300,6 +300,37 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
|
||||
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
|
||||
}
|
||||
|
||||
/// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which
|
||||
/// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many
|
||||
/// anchors went.
|
||||
fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
|
||||
let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6;
|
||||
let before = sp.anchors.len();
|
||||
// The closing pair of a closed path first: taking the last anchor moves no other index.
|
||||
if sp.closed
|
||||
&& before > 2
|
||||
&& picked(0)
|
||||
&& picked(before - 1)
|
||||
&& let [first, .., last] = sp.anchors.as_mut_slice()
|
||||
&& same(first, last)
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
sp.anchors.pop();
|
||||
}
|
||||
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
|
||||
for i in (1..sp.anchors.len()).rev() {
|
||||
if picked(i - 1)
|
||||
&& picked(i)
|
||||
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
|
||||
&& same(a, b)
|
||||
{
|
||||
a.h_out = b.h_out;
|
||||
sp.anchors.remove(i);
|
||||
}
|
||||
}
|
||||
before - sp.anchors.len()
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
@@ -312,6 +343,31 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => selected_paths(s)?,
|
||||
};
|
||||
// Direct-selected anchors lying on one another along a path (two corners brought together by
|
||||
// Average) become one anchor; the paths themselves are joined only when there are none.
|
||||
if p.get("ids").is_none() {
|
||||
let st = s.doc()?;
|
||||
let mut merged = vec![];
|
||||
for (id, picked) in &st.selection.anchors {
|
||||
let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
let n: usize = path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)))).sum();
|
||||
if n > 0 {
|
||||
merged.push((*id, path));
|
||||
}
|
||||
}
|
||||
if !merged.is_empty() {
|
||||
let objects = st.selection.objects.clone();
|
||||
s.edit("Join", |d, sel| {
|
||||
for (id, path) in merged {
|
||||
*path_mut(d, id)? = path;
|
||||
}
|
||||
// The anchor indices changed: the paths stay selected, whole.
|
||||
sel.set(objects);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -578,6 +578,11 @@ fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `id` a path made into a guide (View → Guides → Make Guides)?
|
||||
pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
|
||||
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
|
||||
}
|
||||
|
||||
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
@@ -1295,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));
|
||||
}
|
||||
@@ -1482,10 +1506,18 @@ impl Session {
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
|
||||
if guides_locked {
|
||||
let locked: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| is_guide_path(&st.doc, *id)).collect();
|
||||
for id in locked {
|
||||
st.selection.remove(id);
|
||||
}
|
||||
}
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
@@ -1757,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;
|
||||
@@ -1883,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;
|
||||
@@ -1942,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)
|
||||
}
|
||||
@@ -671,6 +671,108 @@ fn image_trace_make_places_paths_over_the_image() {
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
|
||||
/// circle as its traced path; without the option it is a plain path.
|
||||
#[test]
|
||||
fn image_trace_shapes_makes_live_shapes() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "shapes": true}})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let paths = traced_paths(&n);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &paths[0].kind else { panic!("not a live shape: {:?}", paths[0].kind) };
|
||||
let vectorcraft_doc::LiveShape::Ellipse { w, h, .. } = **live else { panic!("{live:?}") };
|
||||
// A circle of r = 30 px, drawn twice the size.
|
||||
assert!(w == h && (w - 60.0).abs() < 0.75, "{w} × {h}");
|
||||
let bb = path.bounds().unwrap();
|
||||
assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5 && (bb.width() - 120.0).abs() < 1.5, "{bb:?}");
|
||||
assert_eq!(*path, live.to_path());
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
// Off, as documents saved before have it: a path.
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
assert!(matches!(traced_paths(&n)[0].kind, NodeKind::Path { live: None, .. }));
|
||||
}
|
||||
|
||||
/// Auto Grouping: a ring and the dot in its hole are a group (the ring first), the square beside
|
||||
/// them is not in it; the picture is the same as without, and the group traces again, expands
|
||||
/// and releases as any other.
|
||||
#[test]
|
||||
fn image_trace_auto_grouping_groups_what_is_inside() {
|
||||
let art = vectorcraft_trace::Raster::from_fn(120, 80, |x, y| {
|
||||
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 40.0);
|
||||
if d <= 6.0 || (90..110).contains(&x) && (30..50).contains(&y) {
|
||||
[200, 0, 0, 255]
|
||||
} else if (16.0..=30.0).contains(&d) {
|
||||
[0, 0, 160, 255]
|
||||
} else {
|
||||
[255, 255, 255, 255]
|
||||
}
|
||||
});
|
||||
let make = |grouping: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": grouping}})).unwrap();
|
||||
assert_eq!(r["paths"], 3);
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
(s, g)
|
||||
};
|
||||
let (mut s, g) = make(true);
|
||||
let items = traced_paths(&node(&s, g));
|
||||
assert_eq!(items.len(), 2);
|
||||
let group = items.iter().find(|n| matches!(n.kind, NodeKind::Group { .. })).expect("a group");
|
||||
let inside = group.children().unwrap();
|
||||
assert_eq!(inside.len(), 2);
|
||||
assert_eq!(inside[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 160)));
|
||||
assert_eq!(inside[1].appearance.fill_paint(), Paint::solid(Color::rgb8(200, 0, 0)));
|
||||
assert!(matches!(items.iter().find(|n| n.id != group.id).unwrap().kind, NodeKind::Path { .. }));
|
||||
// The same picture as without.
|
||||
let (flat, fg) = make(false);
|
||||
assert_eq!(traced_paths(&node(&flat, fg)).len(), 3);
|
||||
let shot = |s: &Session| {
|
||||
let img = render_doc(s, true);
|
||||
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(shot(&s), shot(&flat));
|
||||
// Traced again without the option, it is flat again; expanded, the groups stay.
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
|
||||
s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": true}})).unwrap();
|
||||
let r = s.execute("imageTrace.expand", &json!({})).unwrap();
|
||||
let expanded = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
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();
|
||||
@@ -795,7 +897,8 @@ fn image_trace_presets_query_and_errors() {
|
||||
let mut s = session();
|
||||
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names.len(), 13);
|
||||
assert_eq!(names.len(), 15);
|
||||
assert!(names.contains(&"Auto Color") && names.contains(&"Outline"));
|
||||
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
|
||||
|
||||
@@ -412,6 +412,29 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight
|
||||
/// sides next to the bump stay straight lines from their far ends, which keep their place.
|
||||
#[test]
|
||||
fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> =
|
||||
[(50.0, 200.0), (190.0, 200.0), (200.0, 192.0), (210.0, 200.0), (350.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})).to_vec();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let before = path(&s, id);
|
||||
drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default());
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the bump is smoothed");
|
||||
let a = &p.subpaths[0].anchors;
|
||||
let (first, second, last) = (a[0], a[1], a[a.len() - 1]);
|
||||
assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}");
|
||||
assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}");
|
||||
// Every other anchor is within the tool's reach of the drag (12 px either way).
|
||||
for x in &a[1..a.len() - 1] {
|
||||
assert!((182.0..=218.0).contains(&x.p.x) && (190.0..=201.0).contains(&x.p.y), "{x:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_tool_joins_two_lines() {
|
||||
let mut s = session();
|
||||
@@ -1141,6 +1164,31 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
|
||||
}
|
||||
|
||||
/// #1533: two corners of a rectangle brought together by Average become one anchor on Join, in
|
||||
/// one undo step; anchors that aren't on one another leave Join as it was.
|
||||
#[test]
|
||||
fn join_merges_the_selected_anchors_average_brought_together() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 100.0, 80.0);
|
||||
let pts = |s: &Session| path(s, r).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::<Vec<_>>();
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
|
||||
assert!(s.execute("path.join", &json!({})).is_err(), "apart: nothing to merge, and the path is closed");
|
||||
s.execute("path.average", &json!({"axis": "both"})).unwrap();
|
||||
s.execute("path.join", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s), [(150.0, 100.0), (200.0, 180.0), (100.0, 180.0)]);
|
||||
assert!(path(&s, r).subpaths[0].closed);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert!(sel.objects == [r] && sel.anchors.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s).len(), 4);
|
||||
// The closing pair: the first and last anchors of a closed path.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 3]], "mode": "set"})).unwrap();
|
||||
s.execute("path.average", &json!({"axis": "both"})).unwrap();
|
||||
let before = pts(&s);
|
||||
s.execute("path.join", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s), before[..3]);
|
||||
}
|
||||
|
||||
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
|
||||
#[test]
|
||||
fn join_takes_the_paths_and_the_ends_to_join() {
|
||||
|
||||
@@ -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();
|
||||
@@ -1249,6 +1344,26 @@ fn ruler_guides_and_lock() {
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
/// #1534: Lock Guides locks the paths made into guides too: they can't be selected (so neither
|
||||
/// moved, reshaped nor deleted), by a click, a marquee or Select All, and locking lets them go.
|
||||
#[test]
|
||||
fn locked_guides_lock_guide_paths() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 20.0, 10.0, 10.0));
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
}
|
||||
|
||||
fn guides(s: &Session) -> Vec<(bool, f64)> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -158,8 +158,11 @@ impl GradientDef {
|
||||
if self.gradient.stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Stops come last first: reversed, stops sharing a ramp point keep their order.
|
||||
self.gradient.stops.reverse();
|
||||
// Stops are listed along the ramp from either end: listed downwards they're reversed, so
|
||||
// stops sharing a ramp point keep their order (and each its midpoint, to the next stop).
|
||||
if self.gradient.stops.first().zip(self.gradient.stops.last()).is_some_and(|(first, last)| first.offset > last.offset) {
|
||||
self.gradient.stops.reverse();
|
||||
}
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Some((self.name, self.gradient))
|
||||
}
|
||||
|
||||
@@ -410,6 +410,19 @@ fn stops_sharing_a_ramp_point_keep_their_order() {
|
||||
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_listed_upwards_keep_the_midpoint_of_the_one_that_starts_the_ramp() {
|
||||
// GUARDS: reversing a list that runs upwards swaps two stops at 0, and the ramp to 100 takes the
|
||||
// midpoint of the zero-length part between them.
|
||||
let defs = "%AI5_BeginGradient: (U)\n(U) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n\
|
||||
%_0 0 0 0 1 1 1 2 1 6 87 0 Bs\n%_0 0 0 0 0 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!("{}Bb\n1 (U) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
let midpoints: Vec<_> = g.gradient.stops.iter().map(|s| s.midpoint).collect();
|
||||
assert_eq!(midpoints, [0.5, 0.87, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_anchor_data_reads() {
|
||||
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
|
||||
@@ -366,9 +366,9 @@ impl Out {
|
||||
/// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which
|
||||
/// clips nothing.
|
||||
fn clip(&mut self, path: BezPath, rule: FillRule) -> Option<Arc<Clip>> {
|
||||
let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..)));
|
||||
let around = path.bounding_box().inflate(0.5, 0.5);
|
||||
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
|
||||
// Only a polygon that really covers the page: a diamond touching its edges has the page as
|
||||
// its bounding box but cuts its corners away (#1542).
|
||||
if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) {
|
||||
return None;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
|
||||
@@ -248,6 +248,12 @@ fn clips_become_clipping_groups() {
|
||||
// eoclip of a path.
|
||||
let o = objects(&read("10 10 moveto 30 10 lineto 30 30 lineto closepath eoclip newpath 0 0 50 50 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
|
||||
// #1542: a diamond touching the page edges has the page as its bounding box but still clips.
|
||||
let o = objects(&read("50 0 moveto 100 50 lineto 50 100 lineto 0 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||||
// A turned square around the whole page clips nothing.
|
||||
let o = objects(&read("50 -60 moveto 160 50 lineto 50 160 lineto -60 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -467,6 +473,22 @@ fn the_bounding_box_comes_from_the_header_or_the_trailer() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("first of the file's 2 pages")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #1552: a Type 42 font's glyph space is one unit an em, so its identity FontMatrix scaled to 12
|
||||
/// sets 12 pt type (a Type 1's 0.001 matrix needs the 1000-unit space).
|
||||
#[test]
|
||||
fn type_42_fonts_set_type_at_their_size() {
|
||||
for (kind, matrix) in [(42, "[1 0 0 1 0 0]"), (1, "[0.001 0 0 0.001 0 0]")] {
|
||||
let body = format!(
|
||||
"/T 10 dict begin /FontName /T def /FontType {kind} def /FontMatrix {matrix} def /Encoding StandardEncoding def currentdict end /T exch definefont pop \
|
||||
/T findfont 12 scalefont setfont 10 20 moveto (Hi) show"
|
||||
);
|
||||
let r = read(&body);
|
||||
let NodeKind::Text(t) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||||
let size = t.runs[0].style.size * t.xf.determinant().abs().sqrt();
|
||||
assert!((size - 12.0).abs() < 1e-6, "FontType {kind}: {size} pt");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_font_programs_are_skipped() {
|
||||
let body = "/MyFont 10 dict begin /FontName /MyFont def /FontMatrix [0.001 0 0 0.001 0 0] def currentdict end currentfile eexec\n\
|
||||
|
||||
@@ -403,7 +403,10 @@ impl Interp<'_> {
|
||||
let f = font.borrow();
|
||||
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
|
||||
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
Ok((name, m * Affine::scale(1000.0)))
|
||||
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
|
||||
// (its FontMatrix, usually the identity, is already the em's).
|
||||
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
|
||||
Ok((name, m * Affine::scale(units)))
|
||||
}
|
||||
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -155,8 +155,8 @@ impl Corner {
|
||||
let (h_out, h_in) = match kind {
|
||||
// An arc tangent to both sides, turning by 180° less the angle between them there.
|
||||
CornerKind::Round => {
|
||||
let h = radius * arc_handle(-cos);
|
||||
(start - du * h, end - dv * h)
|
||||
let (hu, hv) = self.round_handles(radius, (start, du), (end, dv));
|
||||
(start - du * hu, end - dv * hv)
|
||||
}
|
||||
// An arc centred on the corner through both ends, spanning the angle.
|
||||
CornerKind::InvertedRound => {
|
||||
@@ -169,6 +169,33 @@ impl Corner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// The handle lengths of a round cut of `radius` between `start` and `end` (each a point and
|
||||
/// the direction its side runs on in there). Between straight sides they are the arc's. A
|
||||
/// curved side has turned by the time the cut meets it, so the arc's handles would reach past
|
||||
/// where the tangents at the cut's ends cross and the cut would stick out of the corner
|
||||
/// (#1512): there each handle is the share an arc takes of its tangent, up to that crossing
|
||||
/// (a third of the way to the other end where the tangents don't cross between them).
|
||||
fn round_handles(&self, radius: f64, (start, du): (Point, Vec2), (end, dv): (Point, Vec2)) -> (f64, f64) {
|
||||
let cos = du.dot(dv);
|
||||
let arc = arc_handle(-cos);
|
||||
if self.side_u.straight() && self.side_v.straight() {
|
||||
return (radius * arc, radius * arc);
|
||||
}
|
||||
// start − du·a = end − dv·b is where the tangents cross.
|
||||
let (cross, w) = (du.cross(dv), start - end);
|
||||
let (a, b) = (w.cross(dv) / cross, w.cross(du) / cross);
|
||||
if cross.abs() > MIN_SINE && a > EPS && b > EPS && a.is_finite() && b.is_finite() {
|
||||
// An arc's handle over its tangent's length, radius · tan(turn / 2).
|
||||
let share = arc * (1.0 - cos) / cross.abs();
|
||||
(a * share, b * share)
|
||||
} else {
|
||||
let third = start.distance(end) / 3.0;
|
||||
(third, third)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
|
||||
/// and the handles between them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -401,6 +428,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A round cut next to a curve stays inside its corner, however large (#1512): its handles
|
||||
/// stop short of where the tangents at its ends cross, so it doesn't stick out past the sides.
|
||||
#[test]
|
||||
fn a_round_cut_next_to_a_curve_stays_inside_its_corner() {
|
||||
// A square whose top side arches up, meeting the upright sides at an angle.
|
||||
let mut sp =
|
||||
SubPath::polyline(&[Point::new(100.0, 150.0), Point::new(300.0, 150.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], true);
|
||||
sp.anchors[0].h_out = Point::new(120.0, 20.0);
|
||||
sp.anchors[1].h_in = Point::new(280.0, 20.0);
|
||||
let path = PathData::single(sp);
|
||||
let bounds = path.bounds().unwrap().inflate(1e-6, 1e-6);
|
||||
for radius in [10.0, 60.0, 200.0, 1e6] {
|
||||
let (cut, _) = cut_corners(&path, &[radius, radius], &[]);
|
||||
let b = cut.bounds().unwrap();
|
||||
assert!(bounds.union(b) == bounds, "radius {radius}: {b:?} is outside {bounds:?}");
|
||||
// Each cut's handles end before its tangents cross: the cut turns one way only.
|
||||
let a = &cut.subpaths[0].anchors;
|
||||
for (from, to) in [(a[a.len() - 1], a[0]), (a[1], a[2])] {
|
||||
let (t0, t1, chord) = (from.h_out - from.p, to.p - to.h_in, to.p - from.p);
|
||||
assert!(t0.cross(chord) * chord.cross(t1) > 0.0 && t0.cross(t1) * t0.cross(chord) > 0.0, "radius {radius}: {from:?} {to:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
||||
#[test]
|
||||
fn kinds_share_their_ends() {
|
||||
|
||||
@@ -6,6 +6,47 @@ use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
|
||||
use crate::path::{FillRule, PathData};
|
||||
|
||||
/// Does the filled area of `path` cover all of `r` shrunk by `tol` on every side? Answered for one
|
||||
/// convex polygon (a single subpath of straight segments), which covers the rect when it contains
|
||||
/// its four corners; any other path gives `false`, so a caller treats it as not covering.
|
||||
pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
for (i, el) in path.elements().iter().enumerate() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) if i == 0 => pts.push(p),
|
||||
PathEl::LineTo(p) if i > 0 => pts.push(p),
|
||||
PathEl::ClosePath if i > 0 => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if pts.len() > 1 && pts.first() == pts.last() {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
// Convex: every turn goes the same way (straight runs allowed).
|
||||
let n = pts.len();
|
||||
let mut sign = 0.0f64;
|
||||
for i in 0..n {
|
||||
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
|
||||
let cross = (b - a).cross(c - b);
|
||||
if cross.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = cross.signum();
|
||||
} else if cross.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let inner = r.inflate(-tol, -tol);
|
||||
sign != 0.0
|
||||
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
|
||||
.into_iter()
|
||||
.all(|c| fill_contains(path, FillRule::NonZero, c))
|
||||
}
|
||||
|
||||
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
|
||||
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
|
||||
let w = closed(path).winding(p);
|
||||
@@ -94,6 +135,32 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
|
||||
#[test]
|
||||
fn polygon_covers_rect_only_for_convex_polygons_around_it() {
|
||||
let page = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let poly = |pts: &[(f64, f64)]| {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to(pts[0]);
|
||||
for &p in &pts[1..] {
|
||||
b.line_to(p);
|
||||
}
|
||||
b.close_path();
|
||||
b
|
||||
};
|
||||
assert!(polygon_covers_rect(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), page, 0.01));
|
||||
assert!(polygon_covers_rect(&poly(&[(50.0, -60.0), (160.0, 50.0), (50.0, 160.0), (-60.0, 50.0)]), page, 0.01), "turned, around it");
|
||||
// The page's bounding box, but its corners are cut away.
|
||||
assert!(!polygon_covers_rect(&poly(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]), page, 0.01));
|
||||
// Contains the corners but notches into the page: not convex.
|
||||
assert!(!polygon_covers_rect(&poly(&[(-1.0, -1.0), (101.0, -1.0), (101.0, 101.0), (50.0, 40.0), (-1.0, 101.0)]), page, 0.01));
|
||||
// Curves and several subpaths aren't judged.
|
||||
assert!(!polygon_covers_rect(&kurbo::Circle::new((50.0, 50.0), 200.0).to_path(0.1), page, 0.01));
|
||||
let mut two = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
two.extend(poly(&[(10.0, 10.0), (20.0, 10.0), (20.0, 20.0)]));
|
||||
assert!(!polygon_covers_rect(&two, page, 0.01));
|
||||
assert!(!polygon_covers_rect(&BezPath::new(), page, 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_and_stroke() {
|
||||
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+113
-19
@@ -26,6 +26,8 @@ use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_sh
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// Mixed into an image's cache key when it is read white inside an alpha soft mask.
|
||||
const WHITE_KEY: u128 = 0x7717_e000_a1fa;
|
||||
/// The name of the paths text imports as.
|
||||
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
||||
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
|
||||
@@ -197,6 +199,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data);
|
||||
let cache = InterpreterCache::new();
|
||||
let mut x = 0.0;
|
||||
// Where the next page's art may begin (a `.ai` page's art reaches past its artboard).
|
||||
let mut clear = f64::NEG_INFINITY;
|
||||
// Every page only text over private data: a file saved without its PDF part shows a notice
|
||||
// there, not its art. A page that states where its art is isn't that notice.
|
||||
let (mut only_text, mut art_boxes) = (true, false);
|
||||
@@ -220,6 +224,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
|
||||
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
||||
b.page = ab;
|
||||
// The first id of this page's art.
|
||||
let first_id = b.next;
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
if route {
|
||||
let mut seen = HashSet::new();
|
||||
@@ -267,6 +273,33 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
if clipped {
|
||||
b.warn(PAST_PAGE_NOTE);
|
||||
}
|
||||
// A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art
|
||||
// reaches far to the left would lay it over the page before it in the row: there it would
|
||||
// be taken for that page's art (by the check against the editing data, which then finds
|
||||
// art its layers don't have there, and by `place_pages`). Such a page moves right until its
|
||||
// art clears the art before it.
|
||||
let mut art_right = f64::NEG_INFINITY;
|
||||
if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) {
|
||||
// Art at a coordinate that isn't a number (a damaged file) moves nothing.
|
||||
let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0);
|
||||
if dx > 0.0 {
|
||||
let shift = Affine::translate((dx, 0.0));
|
||||
for (_, v) in &mut parts {
|
||||
for n in v.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
}
|
||||
// What the page's lines of type were is told where their glyphs are (the outlines
|
||||
// stroked on them are looked for there), so that moves with them.
|
||||
for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) {
|
||||
for ink in &mut facts.ink {
|
||||
*ink = *ink + kurbo::Vec2::new(dx, 0.0);
|
||||
}
|
||||
}
|
||||
ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1);
|
||||
}
|
||||
art_right = art.x1 + dx;
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
only_text &= ai && is_only_text(&children);
|
||||
art_boxes |= crate::pages::states_art(page);
|
||||
@@ -278,6 +311,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
ab = art;
|
||||
}
|
||||
x = right + opts.artboard_gap;
|
||||
clear = right.max(art_right) + opts.artboard_gap;
|
||||
doc.artboards.push(Artboard {
|
||||
id: i as u32 + 1,
|
||||
name: format!("Artboard {}", i + 1),
|
||||
@@ -756,6 +790,10 @@ struct Builder<'p> {
|
||||
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
|
||||
pending: bool,
|
||||
nested: u32,
|
||||
/// Reading an alpha soft mask's art: images are read white with their own alpha, so the
|
||||
/// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape,
|
||||
/// rasterizes masks into such images).
|
||||
white_images: bool,
|
||||
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
|
||||
last_fill: Option<(NodeId, BezPath)>,
|
||||
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
|
||||
@@ -898,6 +936,7 @@ impl<'p> Builder<'p> {
|
||||
marked: vec![],
|
||||
pending: false,
|
||||
nested: 0,
|
||||
white_images: false,
|
||||
last_fill: None,
|
||||
lines: HashMap::new(),
|
||||
}
|
||||
@@ -1202,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).
|
||||
@@ -1267,7 +1345,10 @@ impl<'p> Builder<'p> {
|
||||
return s.clone();
|
||||
}
|
||||
let page = self.page;
|
||||
let spec = match self.nested(page, |b| m.interpret(b)) {
|
||||
let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha);
|
||||
let art = self.nested(page, |b| m.interpret(b));
|
||||
self.white_images = saved;
|
||||
let spec = match art {
|
||||
Some(art) => {
|
||||
let [r, g, b, _] = m.background_color().to_rgba().components();
|
||||
let backdrop = luminance(&Color::rgb(r, g, b));
|
||||
@@ -1298,10 +1379,15 @@ impl<'p> Builder<'p> {
|
||||
{
|
||||
return Some(Paint::Pattern { pattern: name.clone(), xf });
|
||||
}
|
||||
// A swatch is shared with uses outside an alpha mask, and pattern paints keep their
|
||||
// colours there: its images are read as they are.
|
||||
let white = std::mem::take(&mut self.white_images);
|
||||
let art = self.nested(Rect::ZERO, |b| {
|
||||
// A cell whose content can't be decoded is empty.
|
||||
let _ = t.interpret(b, Affine::IDENTITY, stroke);
|
||||
})?;
|
||||
});
|
||||
self.white_images = white;
|
||||
let art = art?;
|
||||
let sig = paints(&art);
|
||||
let entry = self.pattern_keys.entry(key).or_default();
|
||||
entry.0 += 1;
|
||||
@@ -1620,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();
|
||||
@@ -1722,14 +1812,12 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
(*isolate, *knockout) = (i, k);
|
||||
}
|
||||
}
|
||||
let bb = clip_path.path.bounding_box();
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them.
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them. Only
|
||||
// a polygon that really covers it counts: a diamond touching the page edges has the page
|
||||
// as its bounding box but cuts its corners away (#1542).
|
||||
let redundant = clip_path.path.elements().len() <= 6
|
||||
&& bb.x0 <= self.page.x0 + 0.01
|
||||
&& bb.y0 <= self.page.y0 + 0.01
|
||||
&& bb.x1 >= self.page.x1 - 0.01
|
||||
&& bb.y1 >= self.page.y1 - 0.01
|
||||
&& self.page.area() > 0.0;
|
||||
&& self.page.area() > 0.0
|
||||
&& vectorcraft_geom::hit::polygon_covers_rect(&clip_path.path, self.page, 0.01);
|
||||
let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) };
|
||||
self.stack.push(Frame::new(kind));
|
||||
}
|
||||
@@ -1802,12 +1890,14 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.pending = false;
|
||||
match image {
|
||||
Image::Raster(r) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r);
|
||||
let white = self.white_images;
|
||||
// Inside an alpha mask the same image is read again, white (its own cache key).
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 };
|
||||
let st = r.stream();
|
||||
let (w, h) = (r.width(), r.height());
|
||||
// CMYK images keep their samples (read once per image).
|
||||
let known = self.cmyk_keys.contains(&key);
|
||||
let cmyk = if known {
|
||||
let known = !white && self.cmyk_keys.contains(&key);
|
||||
let cmyk = if known || white {
|
||||
None
|
||||
} else {
|
||||
crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| {
|
||||
@@ -1826,7 +1916,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let dict = st.dict();
|
||||
let filters = st.filters();
|
||||
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
|
||||
let jpeg = filters.len() == 1
|
||||
let jpeg = !white
|
||||
&& filters.len() == 1
|
||||
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
|
||||
&& cs_ok
|
||||
&& !dict.contains_key(b"SMask")
|
||||
@@ -1843,6 +1934,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
|
||||
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
|
||||
let rgba: Vec<u8> = match img {
|
||||
_ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(),
|
||||
ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
|
||||
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
|
||||
};
|
||||
@@ -1858,11 +1950,13 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf);
|
||||
}
|
||||
Image::Stencil(s) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s);
|
||||
let white = self.white_images;
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 };
|
||||
let mut decoded: Option<Decoded> = None;
|
||||
s.with_stencil(
|
||||
|luma, paint| {
|
||||
let rgb = match paint {
|
||||
_ if white => [255, 255, 255, 255],
|
||||
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
|
||||
_ => [0, 0, 0, 255],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -78,6 +78,58 @@ fn the_mask(d: &Document) -> (Node, OpacityMask) {
|
||||
(n, m)
|
||||
}
|
||||
|
||||
/// The two pixels of the image a soft mask of kind `s` draws: black, opaque on the left and clear
|
||||
/// on the right (its own `/SMask`).
|
||||
fn mask_image_pixels(s: &str) -> [[u8; 4]; 2] {
|
||||
let g = first_extra(1);
|
||||
let group = stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Im0 {} 0 R >> >>",
|
||||
g + 1
|
||||
),
|
||||
"q 100 0 0 100 0 0 cm /Im0 Do Q",
|
||||
);
|
||||
let image = stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /SMask {} 0 R",
|
||||
g + 2
|
||||
),
|
||||
"000000000000>",
|
||||
);
|
||||
let smask =
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>");
|
||||
let bytes = one_page(
|
||||
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
|
||||
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /{s} /G {g} 0 R >> >> >>"),
|
||||
&[&group, &image, &smask],
|
||||
);
|
||||
let d = open(&bytes);
|
||||
let (_, m) = the_mask(&d);
|
||||
let mut keys = vec![];
|
||||
m.art.walk(&mut |n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
keys.push(im.key.clone());
|
||||
}
|
||||
});
|
||||
let [key] = keys.as_slice() else { panic!("one image in the mask art: {keys:?}") };
|
||||
let px = image::load_from_memory(&d.images[key].bytes).unwrap().to_rgba8();
|
||||
[px.get_pixel(0, 0).0, px.get_pixel(1, 0).0]
|
||||
}
|
||||
|
||||
/// An alpha soft mask whose art is an image with its own alpha (some PDF writers rasterize masks
|
||||
/// so): the image is read white with that alpha, so the mask's luminance is the alpha, not the
|
||||
/// image's colour (ISO 32000-1 11.5.2: an alpha mask takes the group's alpha, not its colour).
|
||||
#[test]
|
||||
fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() {
|
||||
assert_eq!(mask_image_pixels("Alpha"), [[255, 255, 255, 255], [255, 255, 255, 0]]);
|
||||
}
|
||||
|
||||
/// A luminosity soft mask takes the group's colour (ISO 32000-1 11.5.3): its image stays black.
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_of_an_image_keeps_the_images_colour() {
|
||||
assert_eq!(mask_image_pixels("Luminosity"), [[0, 0, 0, 255], [0, 0, 0, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
|
||||
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
|
||||
@@ -544,3 +596,16 @@ fn glyphs_closed_up_by_half_an_em_stay_one_line() {
|
||||
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), "Tight");
|
||||
}
|
||||
|
||||
/// #1542: a clip only counts as covering the page when it does; a diamond whose bounding box is
|
||||
/// the page still clips, a page rectangle or a turned square around it doesn't make a group.
|
||||
#[test]
|
||||
fn clips_with_the_page_as_bounding_box_still_clip() {
|
||||
let clipped = |clip: &str| {
|
||||
let d = open(&one_page(&format!("q {clip} W n 1 0.5 0 rg 0 0 100 100 re f Q"), "", &[]));
|
||||
all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))
|
||||
};
|
||||
assert!(clipped("50 0 m 100 50 l 50 100 l 0 50 l h"), "diamond");
|
||||
assert!(!clipped("0 0 100 100 re"), "the page");
|
||||
assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page");
|
||||
}
|
||||
@@ -131,6 +131,47 @@ fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A `.ai` page moved right in the row, so its art past the artboard clears the page before it
|
||||
/// (#1528), still has the stroke drawn on its type's glyphs read as the type's stroke.
|
||||
#[test]
|
||||
fn a_stroke_on_the_type_of_a_moved_ai_page_is_still_the_types_stroke() {
|
||||
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
|
||||
let content = format!(
|
||||
"0 g -70 10 80 20 re f BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET 0 0 1 RG 0.5 w {}{}",
|
||||
outline('H', 10.0),
|
||||
outline('i', 10.0 + 0.722 * 24.0)
|
||||
);
|
||||
let pages = [("0 g 10 10 20 20 re f", String::new()), (content.as_str(), HELVETICA.to_string())].map(|(c, resources)| PdfPage {
|
||||
entries: ai.into(),
|
||||
resources,
|
||||
..PdfPage::new(100.0, 100.0, c)
|
||||
});
|
||||
let d = import(&vectorcraft_testkit::pdf::pdf(&pages, None)).unwrap();
|
||||
assert!(d.artboards[1].rect.x0 > d.artboards[0].rect.x1 + 36.0 + 1.0, "page 2 moved: {:?}", d.artboards);
|
||||
let art = d.layers[1].children().unwrap();
|
||||
assert_eq!(art.len(), 2, "the note and the type: {art:?}");
|
||||
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)
|
||||
|
||||
@@ -158,6 +158,27 @@ fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
|
||||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
|
||||
}
|
||||
|
||||
/// A `.ai` keeps its art past the artboard edge (#646): a note mostly left of artboard 2 (as notes
|
||||
/// for a design are put beside it) doesn't lie over artboard 1 in the row, where it would be taken
|
||||
/// for that page's art: page 2 moves right until its art clears page 1's.
|
||||
#[test]
|
||||
fn art_of_an_ai_page_reaching_left_does_not_lie_over_the_page_before() {
|
||||
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
|
||||
let page = |content: &str| PdfPage { entries: ai.into(), ..PdfPage::new(100.0, 100.0, content) };
|
||||
// Page 2's note runs from 70 points left of its box to 10 points into it.
|
||||
let pages = [page("0 g 10 10 20 20 re f"), page("1 1 1 rg 1 0 0 RG -70 10 80 60 re B")];
|
||||
let d = open(&pdf(&pages, None), ImportOptions::default());
|
||||
let (a, b) = (d.artboards[0].rect, d.artboards[1].rect);
|
||||
let note = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!(note.x0 >= a.x1 + 36.0, "the note clears artboard 1: {note:?} {a:?}");
|
||||
assert!((b.x0 - note.x0 - 70.0).abs() < 1.0, "and keeps its place by artboard 2: {note:?} {b:?}");
|
||||
assert_eq!((b.width(), b.height()), (100.0, 100.0));
|
||||
// A plain PDF clips its art to the page: its pages stay 36 points apart.
|
||||
let plain = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "1 0 0 RG -70 10 80 60 re S")];
|
||||
let d = open(&pdf(&plain, None), ImportOptions::default());
|
||||
assert_eq!(d.artboards[1].rect.x0, d.artboards[0].rect.x1 + 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_pdf_opens_with_its_password_only() {
|
||||
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,9 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
|
||||
// On whole pixels at full size the cheapest sampler is exact.
|
||||
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
// The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
|
||||
// radial or freeform gradient) would resample it (#1536).
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
@@ -617,6 +620,7 @@ impl Renderer {
|
||||
ctx.set_transform(Affine::translate((x, y)));
|
||||
ctx.set_blend_mode(blend_mode(mode));
|
||||
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
|
||||
@@ -96,6 +96,9 @@ pub struct RenderOptions {
|
||||
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
||||
/// Color), RGB.
|
||||
pub tile_edge: [u8; 3],
|
||||
/// Colour and style of the paths made into guides (Preferences › Guides & Grid, as the ruler
|
||||
/// guides the UI draws): RGB, and whether they are dotted.
|
||||
pub guide: ([u8; 3], bool),
|
||||
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
|
||||
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
|
||||
pub mask_view: Option<NodeId>,
|
||||
@@ -201,6 +204,7 @@ impl Default for RenderOptions {
|
||||
trim: false,
|
||||
skip_templates: false,
|
||||
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
|
||||
guide: ([0x4a, 0xd8, 0xff], false),
|
||||
mask_view: None,
|
||||
highlight_substitutions: false,
|
||||
anti_alias: AntiAlias::Art,
|
||||
@@ -1158,7 +1162,17 @@ impl Renderer {
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
if *guide {
|
||||
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
|
||||
let ([r, g, b], dots) = f.opts.guide;
|
||||
if dots {
|
||||
let mut screen = bp.clone();
|
||||
screen.apply_affine(f.view);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.5).with_dashes(0.0, [1.5, 2.5]));
|
||||
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
|
||||
ctx.stroke_path(&screen);
|
||||
} else {
|
||||
self.hairline(ctx, f, &bp, [r, g, b, 255]);
|
||||
}
|
||||
} else {
|
||||
self.draw_shape(ctx, f, n, &bp, *rule);
|
||||
}
|
||||
|
||||
+174
-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)
|
||||
}
|
||||
@@ -274,6 +278,58 @@ mod tests {
|
||||
p[0] > 200 && p[1] > 200 && p[2] > 200
|
||||
}
|
||||
|
||||
/// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the
|
||||
/// pattern's paint transform must not resample the effect's raster).
|
||||
#[test]
|
||||
fn effects_after_a_pattern_fill_keep_their_place_and_size() {
|
||||
let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
|
||||
for effect in [
|
||||
fx("blur.gaussian", serde_json::json!({"radius": 4.0})),
|
||||
fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})),
|
||||
] {
|
||||
let make = |with_pattern: bool| {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let l = d.layers[0].id;
|
||||
let filled = d.children(Some(l)).unwrap()[0].id;
|
||||
d.remove(filled).unwrap();
|
||||
if with_pattern {
|
||||
let id = d.alloc_id();
|
||||
let n = Node::path(
|
||||
id,
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)),
|
||||
Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0),
|
||||
);
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(
|
||||
id,
|
||||
shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(effect.clone());
|
||||
let at = d.children(Some(l)).map_or(0, Vec::len);
|
||||
d.insert(Some(l), at, n).unwrap();
|
||||
d
|
||||
};
|
||||
// Supersampled, as raster exports draw.
|
||||
let one = |d: &Document| {
|
||||
let mut r = Renderer::new();
|
||||
r.render(
|
||||
d,
|
||||
200,
|
||||
200,
|
||||
Affine::IDENTITY,
|
||||
&RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() },
|
||||
)
|
||||
};
|
||||
let (with, without) = (one(&make(true)), one(&make(false)));
|
||||
for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] {
|
||||
assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_periodic() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
@@ -327,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");
|
||||
}
|
||||
@@ -67,3 +67,31 @@ fn swatch_bounds_outline_the_period_dashed() {
|
||||
let off = render(&editing(tt, false, false), Some([0, 200, 0]));
|
||||
assert!((51..70).all(|x| is_white(off.pixel(x, 90))));
|
||||
}
|
||||
|
||||
/// #1534: a path made into a guide is drawn in the guide colour and style (Preferences › Guides &
|
||||
/// Grid), as ruler guides are: a line, or dots with gaps between them.
|
||||
#[test]
|
||||
fn guide_paths_take_the_guide_color_and_style() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let (layer, id) = (d.layers[0].id, d.alloc_id());
|
||||
let mut n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { guide, .. } = &mut n.kind {
|
||||
*guide = true;
|
||||
}
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let draw = |guide| {
|
||||
let opts = RenderOptions { background: Some([255; 4]), guide, ..Default::default() };
|
||||
Renderer::new().render(&d, 120, 120, Affine::translate((50.5, 50.5)), &opts)
|
||||
};
|
||||
// Along the top edge of the square (y = 50), between its corners: the pixels the red covers.
|
||||
let inked = |r: &Rendered| (55..105).filter(|x| r.pixel(*x, 50)[1] < 64).count();
|
||||
let line = draw(([200, 0, 0], false));
|
||||
let p = line.pixel(80, 50);
|
||||
assert!(p[0] > 150 && p[1] < 100 && p[2] < 100, "{p:?}");
|
||||
assert_eq!(inked(&line), 50);
|
||||
let dotted = draw(([200, 0, 0], true));
|
||||
let dots = inked(&dotted);
|
||||
assert!((10..40).contains(&dots), "{dots}: {:?}", (45..70).map(|x| dotted.pixel(x, 50)[1]).collect::<Vec<_>>());
|
||||
let default = draw(RenderOptions::default().guide);
|
||||
assert!((55..105).all(|x| !is_white(default.pixel(x, 50))), "a line by default");
|
||||
}
|
||||
@@ -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
@@ -50,6 +50,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
body: String::new(),
|
||||
defs: String::new(),
|
||||
classes: Vec::new(),
|
||||
class_ids: HashMap::new(),
|
||||
used_ids: HashSet::new(),
|
||||
names: HashMap::new(),
|
||||
depth: 1,
|
||||
@@ -259,8 +260,10 @@ struct Writer<'a> {
|
||||
xf: Affine,
|
||||
body: String,
|
||||
defs: String,
|
||||
/// Unique CSS declaration blocks; class name is `cls-{index + 1}`.
|
||||
/// Unique CSS declaration blocks, in first-use order (used for deterministic output).
|
||||
classes: Vec<String>,
|
||||
/// 1-based class/entity number by declaration; avoids a linear scan for each style.
|
||||
class_ids: HashMap<String, usize>,
|
||||
used_ids: HashSet<String>,
|
||||
names: HashMap<NodeId, String>,
|
||||
depth: usize,
|
||||
@@ -657,13 +660,13 @@ impl Writer<'_> {
|
||||
|
||||
/// The 1-based number of a distinct declaration block (a CSS class or a style entity).
|
||||
fn class(&mut self, decl: String) -> usize {
|
||||
match self.classes.iter().position(|c| *c == decl) {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
self.classes.push(decl);
|
||||
self.classes.len()
|
||||
}
|
||||
if let Some(&i) = self.class_ids.get(decl.as_str()) {
|
||||
return i;
|
||||
}
|
||||
let i = self.classes.len() + 1;
|
||||
self.class_ids.insert(decl.clone(), i);
|
||||
self.classes.push(decl);
|
||||
i
|
||||
}
|
||||
|
||||
/// `<metadata>`: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the
|
||||
|
||||
@@ -21,9 +21,11 @@ use text::TextSlots;
|
||||
pub(crate) use fx::BLEND;
|
||||
|
||||
mod css;
|
||||
mod css_transform;
|
||||
mod files;
|
||||
mod fx;
|
||||
mod spread;
|
||||
mod symbol_units;
|
||||
mod text;
|
||||
mod vector_effect;
|
||||
|
||||
@@ -81,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
|
||||
@@ -243,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;
|
||||
|
||||
@@ -464,11 +533,15 @@ impl Edits {
|
||||
/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded,
|
||||
/// to come back hidden; one something links to (a `<use>` template) stays as it is;
|
||||
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
|
||||
/// * linked files are read ([`files`]).
|
||||
/// * linked files are read ([`files`]);
|
||||
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]).
|
||||
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
let mut found = Found::default();
|
||||
// (`:` for prefixed elements such as `<svg:image>`.)
|
||||
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t)) {
|
||||
let css_transform = has_css_transform(svg);
|
||||
if !css_transform
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t))
|
||||
{
|
||||
return (svg.into(), found);
|
||||
}
|
||||
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
||||
@@ -507,9 +580,20 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
}
|
||||
}
|
||||
found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings);
|
||||
if !symbols.is_empty() && svg.contains('%') {
|
||||
edits.list.extend(symbol_units::rewrites(&xml));
|
||||
}
|
||||
if css_transform {
|
||||
edits.list.extend(css_transform::rewrites(svg, &xml));
|
||||
}
|
||||
(edits.apply(svg), found)
|
||||
}
|
||||
|
||||
/// Is there a `transform` CSS declaration in `svg` (`transform` followed by `:`)?
|
||||
fn has_css_transform(svg: &str) -> bool {
|
||||
svg.match_indices("transform").any(|(i, t)| svg.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':')))
|
||||
}
|
||||
|
||||
/// The elements that are objects (what [`hidden_objects`] shows).
|
||||
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];
|
||||
|
||||
@@ -885,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![]
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,8 @@ use usvg::roxmltree;
|
||||
|
||||
pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
|
||||
|
||||
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag.
|
||||
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
|
||||
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
|
||||
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
s.split(';')
|
||||
.filter_map(|d| {
|
||||
@@ -27,9 +28,28 @@ fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
};
|
||||
Some((k.trim().to_ascii_lowercase(), v.to_string(), important))
|
||||
})
|
||||
.flat_map(|(k, v, important)| match k.as_str() {
|
||||
"font" => font_longhands(&v).into_iter().map(|(k, v)| (k.to_string(), v, important)).collect(),
|
||||
_ => vec![(k, v, important)],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The longhands of a `font` shorthand: those it leaves out back to their initial values. None
|
||||
/// when it doesn't parse (it is then ignored, as in CSS).
|
||||
fn font_longhands(v: &str) -> Vec<(&'static str, String)> {
|
||||
let Ok(f) = svgtypes::FontShorthand::from_str(v) else { return Vec::new() };
|
||||
let or = |o: Option<&str>| o.unwrap_or("normal").to_string();
|
||||
vec![
|
||||
("font-style", or(f.font_style)),
|
||||
("font-variant", or(f.font_variant)),
|
||||
("font-weight", or(f.font_weight)),
|
||||
("font-stretch", or(f.font_stretch)),
|
||||
("font-size", f.font_size.to_string()),
|
||||
("font-family", f.font_family.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// One compound selector (`text.a.b#c[x=y]`).
|
||||
#[derive(Debug, Default)]
|
||||
struct Compound {
|
||||
@@ -343,6 +363,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_shorthand_sets_its_longhands() {
|
||||
let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense");
|
||||
let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::<Vec<_>>();
|
||||
assert_eq!(get("font-style"), [("italic", false)]);
|
||||
assert_eq!(get("font-weight"), [("bold", false), ("300", true)]);
|
||||
assert_eq!(get("font-size"), [("30px", false)]);
|
||||
assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]);
|
||||
assert_eq!(get("font-variant"), [("normal", false)]);
|
||||
assert_eq!(get("fill"), [("red", false)]);
|
||||
assert!(get("font").is_empty(), "the shorthand itself is not kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_selectors_are_dropped() {
|
||||
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! CSS `transform` declarations in SVG syntax, for usvg.
|
||||
//!
|
||||
//! usvg takes the `transform` property (from a `style` attribute or a `<style>` rule) as a
|
||||
//! presentation attribute that overrides the element's `transform` attribute, but reads its value
|
||||
//! as SVG, without CSS units: `translate(100px, 0)` or `rotate(45deg)` fails, and the element is
|
||||
//! left with no transform at all (#1557). Before usvg runs, such values are rewritten in SVG
|
||||
//! syntax: lengths in `px` (or unitless), angles in `deg`, `rad`, `grad` or `turn`, and the CSS
|
||||
//! functions SVG lacks (`translateX`, `scaleY`, `skew`…) spelled with SVG's. A value that can't be
|
||||
//! (percentages, `em`, 3D functions) is left as it is.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
use std::ops::Range;
|
||||
|
||||
use usvg::roxmltree;
|
||||
|
||||
use super::css::XNode;
|
||||
|
||||
/// The CSS transform `value` in SVG syntax; `None` when it has nothing to rewrite or can't be.
|
||||
pub(super) fn to_svg(value: &str) -> Option<String> {
|
||||
let v = value.trim();
|
||||
if v.eq_ignore_ascii_case("none") {
|
||||
return Some("matrix(1 0 0 1 0 0)".into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut rest = v;
|
||||
while !rest.trim_start().is_empty() {
|
||||
rest = rest.trim_start();
|
||||
let open = rest.find('(')?;
|
||||
let close = rest.find(')')?;
|
||||
let name = rest.get(..open)?.trim().to_ascii_lowercase();
|
||||
let args: Vec<&str> = rest.get(open + 1..close)?.split(|c: char| c == ',' || c.is_whitespace()).filter(|a| !a.is_empty()).collect();
|
||||
let len = |i: usize| args.get(i).and_then(|a| length(a));
|
||||
let ang = |i: usize| args.get(i).and_then(|a| angle(a));
|
||||
let num = |i: usize| args.get(i).and_then(|a| a.parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let f = match (name.as_str(), args.len()) {
|
||||
("matrix", 6) => format!("matrix({} {} {} {} {} {})", num(0)?, num(1)?, num(2)?, num(3)?, len(4)?, len(5)?),
|
||||
("translate", 1) => format!("translate({} 0)", len(0)?),
|
||||
("translate", 2) => format!("translate({} {})", len(0)?, len(1)?),
|
||||
("translatex", 1) => format!("translate({} 0)", len(0)?),
|
||||
("translatey", 1) => format!("translate(0 {})", len(0)?),
|
||||
("scale", 1) => format!("scale({})", num(0)?),
|
||||
("scale", 2) => format!("scale({} {})", num(0)?, num(1)?),
|
||||
("scalex", 1) => format!("scale({} 1)", num(0)?),
|
||||
("scaley", 1) => format!("scale(1 {})", num(0)?),
|
||||
("rotate", 1) => format!("rotate({})", ang(0)?),
|
||||
("skewx", 1) => format!("skewX({})", ang(0)?),
|
||||
("skewy", 1) => format!("skewY({})", ang(0)?),
|
||||
("skew", 1) => format!("skewX({})", ang(0)?),
|
||||
("skew", 2) => format!("matrix(1 {} {} 1 0 0)", ang(1)?.to_radians().tan(), ang(0)?.to_radians().tan()),
|
||||
_ => return None,
|
||||
};
|
||||
out.push(f);
|
||||
rest = rest.get(close + 1..)?;
|
||||
}
|
||||
let svg = out.join(" ");
|
||||
(!out.is_empty() && svg != v).then_some(svg)
|
||||
}
|
||||
|
||||
/// A length in user units: `px` or unitless.
|
||||
fn length(a: &str) -> Option<f64> {
|
||||
let n = a.strip_suffix("px").unwrap_or(a).parse::<f64>().ok()?;
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
/// An angle in degrees.
|
||||
fn angle(a: &str) -> Option<f64> {
|
||||
let a = a.to_ascii_lowercase();
|
||||
let (n, k) = if let Some(n) = a.strip_suffix("deg") {
|
||||
(n, 1.0)
|
||||
} else if let Some(n) = a.strip_suffix("grad") {
|
||||
(n, 0.9)
|
||||
} else if let Some(n) = a.strip_suffix("rad") {
|
||||
(n, 180.0 / PI)
|
||||
} else if let Some(n) = a.strip_suffix("turn") {
|
||||
(n, 360.0)
|
||||
} else {
|
||||
(a.as_str(), 1.0)
|
||||
};
|
||||
let d = n.parse::<f64>().ok()? * k;
|
||||
d.is_finite().then_some(d)
|
||||
}
|
||||
|
||||
/// Where CSS can be in `svg`: `style` attribute values and `<style>` contents.
|
||||
fn css_ranges(xml: &roxmltree::Document) -> Vec<Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
for n in xml.descendants().filter(XNode::is_element) {
|
||||
if let Some(a) = n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) {
|
||||
out.push(a.range_value());
|
||||
}
|
||||
if n.tag_name().name() == "style" {
|
||||
out.extend(n.children().filter(XNode::is_text).map(|t| t.range()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `transform` declarations in CSS in `svg`, each as (value range, value in SVG syntax).
|
||||
pub(super) fn rewrites(svg: &str, xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
|
||||
let mut out = Vec::new();
|
||||
for r in css_ranges(xml) {
|
||||
let Some(css) = svg.get(r.clone()) else { continue };
|
||||
let mut from = 0;
|
||||
while let Some(i) = css.get(from..).and_then(|s| s.find("transform")).map(|i| i + from) {
|
||||
from = i + "transform".len();
|
||||
let before = css.get(..i).and_then(|s| s.chars().next_back());
|
||||
if before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
continue;
|
||||
}
|
||||
let after = css.get(from..).unwrap_or("");
|
||||
let Some(colon) = after.find(|c: char| !c.is_whitespace()).filter(|&k| after.get(k..).is_some_and(|s| s.starts_with(':'))) else {
|
||||
continue;
|
||||
};
|
||||
let start = from + colon + 1;
|
||||
let len = css.get(start..).and_then(|s| s.find([';', '}', '!', '"', '\'', '<'])).unwrap_or(css.len() - start);
|
||||
let Some(value) = css.get(start..start + len) else { continue };
|
||||
if let Some(svg_value) = to_svg(value) {
|
||||
out.push((r.start + start..r.start + start + len, format!(" {svg_value}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn css_transforms_become_svg() {
|
||||
assert_eq!(to_svg("translate(100px, 100px)").as_deref(), Some("translate(100 100)"));
|
||||
assert_eq!(to_svg(" translateX(5px) rotate(0.25turn) ").as_deref(), Some("translate(5 0) rotate(90)"));
|
||||
assert_eq!(to_svg("rotate(1.5707963267948966rad) scaleY(2)").as_deref(), Some("rotate(90) scale(1 2)"));
|
||||
assert_eq!(to_svg("skew(45deg)").as_deref(), Some("skewX(45)"));
|
||||
assert_eq!(to_svg("matrix(1, 0, 0, 1, 10px, 20px)").as_deref(), Some("matrix(1 0 0 1 10 20)"));
|
||||
assert_eq!(to_svg("none").as_deref(), Some("matrix(1 0 0 1 0 0)"));
|
||||
// Already SVG, or not expressible: left alone.
|
||||
assert_eq!(to_svg("translate(10 20)"), None);
|
||||
assert_eq!(to_svg("translate(50%, 0)"), None);
|
||||
assert_eq!(to_svg("rotate3d(1, 0, 0, 45deg)"), None);
|
||||
assert_eq!(to_svg("translate(1px"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_css_is_rewritten() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><style>.a { fill: red; transform: rotate(45deg) } .b{-webkit-transform:rotate(1deg)}</style><rect class="a" transform="translate(1 2)" style="transform:translate(3px,4px);fill:blue"/><text>transform: rotate(9deg)</text></svg>"#;
|
||||
let xml = roxmltree::Document::parse(svg).unwrap();
|
||||
let found = rewrites(svg, &xml);
|
||||
let r: Vec<(&str, &str)> = found.iter().map(|(r, v)| (&svg[r.clone()], v.as_str())).collect();
|
||||
assert_eq!(r, vec![(" rotate(45deg) ", " rotate(45)"), ("translate(3px,4px)", " translate(3 4)")]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Percentage lengths inside a `<symbol>` with a `viewBox`, as numbers, for usvg.
|
||||
//!
|
||||
//! A percentage is of the nearest viewport's size in user units, and a symbol's `viewBox` sets
|
||||
//! that size for its content as a nested `<svg>`'s does (SVG 2, 8.9 Units; 5.6 The `use` element).
|
||||
//! usvg takes the percentages of the `<use>`'s width and height instead and then scales the art by
|
||||
//! the `viewBox`, so it comes out too large and out of place (#1561). Before usvg runs, the
|
||||
//! percentages of the shapes' geometry are rewritten in the `viewBox`'s units.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::str::FromStr;
|
||||
|
||||
use usvg::roxmltree;
|
||||
|
||||
/// The elements whose geometry attributes are rewritten.
|
||||
const SHAPES: &[&str] = &["rect", "circle", "ellipse", "line", "image", "use", "text"];
|
||||
/// The elements whose content has lengths of its own kind (or a viewport of its own).
|
||||
const SCOPES: &[&str] = &["symbol", "svg", "pattern", "mask", "clipPath", "marker"];
|
||||
|
||||
/// What a percentage of attribute `name` is of, in a viewport of `w` × `h`: its width, its height,
|
||||
/// or (a radius) its diagonal over √2.
|
||||
fn basis(name: &str, w: f64, h: f64) -> Option<f64> {
|
||||
match name {
|
||||
"x" | "x1" | "x2" | "cx" | "width" | "rx" => Some(w),
|
||||
"y" | "y1" | "y2" | "cy" | "height" | "ry" => Some(h),
|
||||
"r" => Some(((w * w + h * h) / 2.0).sqrt()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The percentage geometry of shapes in symbols with a `viewBox`, each as (value range, number).
|
||||
pub(super) fn rewrites(xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
|
||||
let mut out = Vec::new();
|
||||
for n in xml.descendants().filter(|n| n.is_element() && SHAPES.contains(&n.tag_name().name())) {
|
||||
let Some(scope) = n.ancestors().skip(1).find(|a| SCOPES.contains(&a.tag_name().name())) else { continue };
|
||||
let Some(vb) = Some(scope)
|
||||
.filter(|s| s.tag_name().name() == "symbol")
|
||||
.and_then(|s| s.attribute("viewBox"))
|
||||
.and_then(|v| svgtypes::ViewBox::from_str(v).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !(vb.w > 0.0 && vb.h > 0.0 && vb.w.is_finite() && vb.h.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
for a in n.attributes().filter(|a| a.namespace().is_none()) {
|
||||
let percent = a.value().trim().strip_suffix('%').and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
if let (Some(p), Some(of)) = (percent, basis(a.name(), vb.w, vb.h)) {
|
||||
out.push((a.range_value(), (p / 100.0 * of).to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentages_in_a_symbol_are_of_its_view_box() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><rect width="50%"/>
|
||||
<symbol id="a" viewBox="0 0 200 100"><rect x="10%" y="10%" width="100%" height="50%" rx="5"/><circle cx="50%" r="100%"/>
|
||||
<linearGradient x2="100%"/><pattern><rect width="100%"/></pattern><g><line x2="25%" y2=" 25% "/></g></symbol>
|
||||
<symbol id="b"><rect width="100%"/></symbol><use href="#a" width="30%"/></svg>"##;
|
||||
let xml = roxmltree::Document::parse(svg).unwrap();
|
||||
let found: Vec<(&str, String)> = rewrites(&xml).into_iter().map(|(r, v)| (&svg[r], v)).collect();
|
||||
let r = (200.0f64 * 200.0 / 2.0 + 100.0 * 100.0 / 2.0).sqrt().to_string();
|
||||
let expected =
|
||||
[("10%", "20"), ("10%", "10"), ("100%", "200"), ("50%", "50"), ("50%", "100"), ("100%", r.as_str()), ("25%", "50"), (" 25% ", "25")];
|
||||
assert_eq!(found.iter().map(|(a, b)| (*a, b.as_str())).collect::<Vec<_>>(), expected);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -468,10 +471,11 @@ impl Ctx {
|
||||
None => {
|
||||
let id = href(tp)?.trim().strip_prefix('#')?;
|
||||
let el = xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))?;
|
||||
if el.tag_name().name() != "path" {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?;
|
||||
let d = match el.tag_name().name() {
|
||||
"path" => el.attribute("d")?.to_string(),
|
||||
_ => self.shape_path(el)?,
|
||||
};
|
||||
let mut bp = BezPath::from_svg(&d).ok()?;
|
||||
bp.apply_affine(parse_transform(el.attribute("transform")));
|
||||
bp
|
||||
}
|
||||
@@ -483,6 +487,123 @@ impl Ctx {
|
||||
usable.then_some(bp)
|
||||
}
|
||||
|
||||
/// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a
|
||||
/// `<textPath>` that points at it follows. Circles and ellipses start at 3 o'clock and rounded
|
||||
/// rects at the end of the top-left corner, all running clockwise; lines and polylines follow
|
||||
/// their points. `None` for other elements and for shapes that render nothing.
|
||||
fn shape_path(&self, el: XNode) -> Option<String> {
|
||||
let (vw, vh) = self.viewport;
|
||||
let len = |a: &str, pct: f64| el.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct));
|
||||
let x = |a: &str| len(a, vw).unwrap_or(0.0);
|
||||
let y = |a: &str| len(a, vh).unwrap_or(0.0);
|
||||
let points = || -> Vec<f64> { el.attribute("points").map(|v| list(v).map_while(|t| t.parse().ok()).collect()).unwrap_or_default() };
|
||||
let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| {
|
||||
(rx > 0.0 && ry > 0.0).then(|| {
|
||||
format!(
|
||||
"M{} {} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy}Z",
|
||||
cx + rx,
|
||||
cy,
|
||||
cy + ry,
|
||||
cx - rx,
|
||||
cy - ry,
|
||||
cx + rx
|
||||
)
|
||||
})
|
||||
};
|
||||
match el.tag_name().name() {
|
||||
"circle" => {
|
||||
let r = len("r", ((vw * vw + vh * vh) / 2.0).sqrt())?;
|
||||
ellipse(x("cx"), y("cy"), r, r)
|
||||
}
|
||||
"ellipse" => {
|
||||
let (rx, ry) = (len("rx", vw), len("ry", vh));
|
||||
let (rx, ry) = (rx.or(ry)?, ry.or(rx)?);
|
||||
ellipse(x("cx"), y("cy"), rx, ry)
|
||||
}
|
||||
"rect" => {
|
||||
let (x0, y0, w, h) = (x("x"), y("y"), len("width", vw)?, len("height", vh)?);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (rx, ry) = (len("rx", vw).filter(|v| *v > 0.0), len("ry", vh).filter(|v| *v > 0.0));
|
||||
let (rx, ry) = (rx.or(ry).unwrap_or(0.0).min(w / 2.0), ry.or(rx).unwrap_or(0.0).min(h / 2.0));
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return Some(format!("M{x0} {y0} H{} V{} H{x0}Z", x0 + w, y0 + h));
|
||||
}
|
||||
let (x1, y1) = (x0 + w, y0 + h);
|
||||
Some(format!(
|
||||
"M{} {y0} H{} A{rx} {ry} 0 0 1 {x1} {} V{} A{rx} {ry} 0 0 1 {} {y1} H{} A{rx} {ry} 0 0 1 {x0} {} V{} A{rx} {ry} 0 0 1 {} {y0}Z",
|
||||
x0 + rx,
|
||||
x1 - rx,
|
||||
y0 + ry,
|
||||
y1 - ry,
|
||||
x1 - rx,
|
||||
x0 + rx,
|
||||
y1 - ry,
|
||||
y0 + ry,
|
||||
x0 + rx
|
||||
))
|
||||
}
|
||||
"line" => Some(format!("M{} {} L{} {}", x("x1"), y("y1"), x("x2"), y("y2"))),
|
||||
tag @ ("polyline" | "polygon") => {
|
||||
let pts = points();
|
||||
let mut d = String::new();
|
||||
for (i, [x, y]) in pts.as_chunks::<2>().0.iter().enumerate() {
|
||||
let _ = write!(d, "{}{x} {y} ", if i == 0 { 'M' } else { 'L' });
|
||||
}
|
||||
if d.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if tag == "polygon" {
|
||||
d.push('Z');
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
|
||||
@@ -181,3 +181,79 @@ fn reflect_and_repeat_match_resvg() {
|
||||
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1557: `transform` as a CSS property, in CSS syntax, moves the object (and wins over the
|
||||
/// attribute, as in CSS), from a `style` attribute or a `<style>` rule.
|
||||
#[test]
|
||||
fn css_transforms_move_objects() {
|
||||
let d = import_with(
|
||||
&svg(r#"<style>.r { transform: rotate(90deg) }</style>
|
||||
<rect id="a" x="0" y="0" width="50" height="50" fill="red" style="transform: translate(100px, 100px)"/>
|
||||
<rect id="b" x="0" y="0" width="50" height="50" fill="blue" transform="translate(0 100)" style="transform: translateX(100px)"/>
|
||||
<rect id="c" class="r" x="0" y="0" width="50" height="20" fill="green"/>"#),
|
||||
&ImportOptions::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let b: Vec<Rect> = art(&d).iter().map(|n| n.geometric_bounds().unwrap()).collect();
|
||||
let near =
|
||||
|r: Rect, x0: f64, y0: f64, x1: f64, y1: f64| [(r.x0, x0), (r.y0, y0), (r.x1, x1), (r.y1, y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6);
|
||||
assert!(near(b[0], 100.0, 100.0, 150.0, 150.0), "{:?}", b[0]);
|
||||
assert!(near(b[1], 100.0, 0.0, 150.0, 50.0), "{:?}", b[1]);
|
||||
assert!(near(b[2], -20.0, 0.0, 0.0, 50.0), "{:?}", b[2]);
|
||||
}
|
||||
|
||||
/// #1561: percentages in a `<symbol>` with a `viewBox` are of the `viewBox`, so the symbol draws as
|
||||
/// the same one written with numbers.
|
||||
#[test]
|
||||
fn percentages_in_a_symbol_are_of_its_view_box() {
|
||||
let drawing = |w: &str, h: &str, cx: &str, cy: &str| {
|
||||
let s = svg(&format!(
|
||||
r##"<defs><symbol id="s" viewBox="0 0 100 50"><rect width="{w}" height="{h}" fill="red"/><circle cx="{cx}" cy="{cy}" r="20" fill="blue"/></symbol></defs>
|
||||
<use xlink:href="#s" x="20" y="10" width="160" height="100"/>"##
|
||||
));
|
||||
render_artboard(&import_with(&s, &ImportOptions::default()).unwrap().0)
|
||||
};
|
||||
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"/>"#)
|
||||
}
|
||||
@@ -518,3 +518,114 @@ fn gradients_on_scaled_text_stay_in_place() {
|
||||
let (start, end) = (t.xf * geom.start, t.xf * geom.end);
|
||||
assert!(start.distance(Point::new(40.0, 0.0)) < 1e-6 && end.distance(Point::new(240.0, 0.0)) < 1e-6, "{start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// The start of a type-on-a-path object's path and a point a little way along it, in document
|
||||
/// space.
|
||||
fn path_start(t: &TextObject) -> (Point, Point) {
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, PathEl};
|
||||
let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let mut bp = path.to_bezpath();
|
||||
bp.apply_affine(t.xf);
|
||||
let PathEl::MoveTo(p0) = bp.elements()[0] else { panic!("{bp:?}") };
|
||||
let next = bp.segments().next().unwrap().eval(0.05);
|
||||
(p0, next)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_path_follows_basic_shapes_as_their_equivalent_paths() {
|
||||
let (d, w) = import_with_report(&svg(r##"<circle id="c" cx="150" cy="150" r="100"/>
|
||||
<ellipse id="e" cx="150" cy="150" rx="80" ry="40" transform="translate(10 0)"/>
|
||||
<rect id="r" x="20" y="30" width="100" height="60" rx="10"/>
|
||||
<rect id="s" x="20" y="30" width="100" height="60"/>
|
||||
<line id="l" x1="10" y1="200" x2="210" y2="200"/>
|
||||
<polyline id="pl" points="10,10 110,10 110,60"/>
|
||||
<polygon id="pg" points="10 10, 60 10, 35 50"/>
|
||||
<text font-size="10"><textPath xlink:href="#c">TEXT ON A CIRCLE</textPath></text>
|
||||
<text font-size="10"><textPath href="#e">Ellipse</textPath></text>
|
||||
<text font-size="10"><textPath href="#r">Rounded</textPath></text>
|
||||
<text font-size="10"><textPath href="#s">Square</textPath></text>
|
||||
<text font-size="10"><textPath href="#l">Line</textPath></text>
|
||||
<text font-size="10"><textPath href="#pl">Polyline</textPath></text>
|
||||
<text font-size="10"><textPath href="#pg">Polygon</textPath></text>"##))
|
||||
.unwrap();
|
||||
assert!(!w.iter().any(|w| w.contains("textPath")), "{w:?}");
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 7);
|
||||
assert!(t.iter().all(|t| matches!(t.kind, TextKind::OnPath { .. })), "{:?}", t.iter().map(|t| &t.kind).collect::<Vec<_>>());
|
||||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-6;
|
||||
// Circle: starts at 3 o'clock and runs clockwise (down first, y pointing down), around the circle.
|
||||
let (p0, next) = path_start(t[0]);
|
||||
assert!(near(p0, Point::new(250.0, 150.0)) && next.y > p0.y && next.x < p0.x, "{p0:?} {next:?}");
|
||||
let TextKind::OnPath { path, .. } = &t[0].kind else { panic!("not on a path") };
|
||||
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
|
||||
assert!(close(b.x0, 50.0, 1e-6) && close(b.y0, 50.0, 1e-6) && close(b.x1, 250.0, 1e-6) && close(b.y1, 250.0, 1e-6), "{b:?}");
|
||||
assert!(path.is_closed());
|
||||
// Ellipse, with the shape's own transform.
|
||||
let (p0, next) = path_start(t[1]);
|
||||
assert!(near(p0, Point::new(240.0, 150.0)) && next.y > p0.y, "{p0:?} {next:?}");
|
||||
// Rounded rect: after the top-left corner, along the top edge to the right.
|
||||
let (p0, next) = path_start(t[2]);
|
||||
assert!(near(p0, Point::new(30.0, 30.0)) && next.x > p0.x && close(next.y, 30.0, 1e-9), "{p0:?} {next:?}");
|
||||
let (p0, next) = path_start(t[3]);
|
||||
assert!(near(p0, Point::new(20.0, 30.0)) && next.x > p0.x, "{p0:?} {next:?}");
|
||||
// Line, polyline and polygon follow their points.
|
||||
assert!(near(path_start(t[4]).0, Point::new(10.0, 200.0)));
|
||||
assert!(near(path_start(t[5]).0, Point::new(10.0, 10.0)));
|
||||
let TextKind::OnPath { path, .. } = &t[6].kind else { panic!("not on a path") };
|
||||
assert!(path.is_closed() && near(path_start(t[6]).0, Point::new(10.0, 10.0)));
|
||||
assert_eq!(t[0].plain_text(), "TEXT ON A CIRCLE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_path_to_an_empty_shape_still_falls_back_to_point_type() {
|
||||
let (d, w) =
|
||||
import_with_report(&svg(r##"<circle id="c" cx="5" cy="5" r="0"/><text x="5" y="10"><textPath href="#c">Zero</textPath></text>"##)).unwrap();
|
||||
assert!(matches!(only_text(&d).kind, TextKind::Point));
|
||||
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
|
||||
}
|
||||
|
||||
/// #1557: the `font` shorthand sets the type's size, weight and style.
|
||||
#[test]
|
||||
fn the_font_shorthand_styles_type() {
|
||||
let d = open(r#"<text x="10" y="40" style="font: italic bold 30px sans-serif">Deco</text>"#);
|
||||
let t = only_text(&d);
|
||||
let st = &t.runs[0].style;
|
||||
assert!(close(st.size * t.xf.determinant().abs().sqrt(), 30.0, 1e-6), "{}", st.size);
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,33 @@ fn doc() -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
/// A large palette should create one class per distinct declaration, preserving first-use
|
||||
/// numbers even when earlier styles recur after hundreds of others.
|
||||
#[test]
|
||||
fn many_css_styles_preserve_first_use_numbers() {
|
||||
let mut d = Document::new(300.0, 10.0);
|
||||
let layer = d.layers[0].id;
|
||||
let colors = (0u16..256).chain([0, 128, 255]);
|
||||
for (x, color) in colors.enumerate() {
|
||||
let x = x as f64;
|
||||
let appearance = Appearance::basic(Paint::solid(Color::rgb8(color as u8, 31, 63)), Paint::None, 0.0);
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 0.5, 1.0)), appearance);
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
for (styling, definition_marker) in [(Styling::InternalCss, ".cls-"), (Styling::StyleEntities, "<!ENTITY st")] {
|
||||
let svg = export(&d, &ExportOptions { styling, ..Default::default() });
|
||||
let paths: Vec<_> = svg.lines().filter(|line| line.contains("<path ")).collect();
|
||||
assert_eq!(paths.len(), 259, "{styling:?}");
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
let number = if i < 256 { i + 1 } else { [1, 129, 256][i - 256] };
|
||||
let expected = if styling == Styling::InternalCss { format!("class=\"cls-{number}\"") } else { format!("style=\"&st{number};\"") };
|
||||
assert!(path.contains(&expected), "path {i} should reference {expected}: {path}");
|
||||
}
|
||||
assert_eq!(svg.matches(definition_marker).count(), 256, "{styling:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_combination_reads_back() {
|
||||
let d = doc();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! The font folders fontconfig is set up with (Linux and the other Unix desktops): the `<dir>`
|
||||
//! entries of its configuration, `/etc/fonts/fonts.conf` and what that includes (`conf.d`, the
|
||||
//! user's own files). Distributions and packages add folders outside the usual ones there: TeX
|
||||
//! fonts under `/usr/share/texmf/fonts`, say, which `fc-list` and other apps then show (#1529).
|
||||
//!
|
||||
//! The format is fontconfig's documented one
|
||||
//! (<https://www.freedesktop.org/software/fontconfig/fontconfig-user.html>, "Configuration file
|
||||
//! format"): `<dir prefix="…">` and `<include prefix="…">` are all that is read here. The files are
|
||||
//! read as they are: fontconfig itself (a library, or `fc-list`) isn't asked.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The most configuration files read, and the largest one (they are a few kilobytes each).
|
||||
const MAX_FILES: usize = 512;
|
||||
const MAX_FILE_BYTES: u64 = 1 << 20;
|
||||
/// The most folders taken, and how deep files may include files.
|
||||
const MAX_DIRS: usize = 256;
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
/// A `<dir>` (a font folder) or an `<include>` (another configuration file, or a folder of them)
|
||||
/// with its `prefix` attribute and its text.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Entry {
|
||||
include: bool,
|
||||
prefix: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// The `<dir>` and `<include>` elements of a configuration file, in order. Anything that isn't
|
||||
/// one of them, comments included, is skipped; a file that isn't well formed gives what was read
|
||||
/// before the damage.
|
||||
fn entries(xml: &str) -> Vec<Entry> {
|
||||
let mut out = vec![];
|
||||
let mut rest = xml;
|
||||
while let Some(at) = rest.find('<') {
|
||||
rest = rest.get(at + 1..).unwrap_or_default();
|
||||
if let Some(comment) = rest.strip_prefix("!--") {
|
||||
rest = comment.split_once("-->").map_or("", |(_, after)| after);
|
||||
continue;
|
||||
}
|
||||
let Some((tag, after)) = rest.split_once('>') else { break };
|
||||
let name = tag.split_whitespace().next().unwrap_or_default();
|
||||
if !matches!(name, "dir" | "include") || tag.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let Some((text, after)) = after.split_once("</") else { break };
|
||||
rest = after;
|
||||
// No element is inside these: text with a tag in it isn't a path.
|
||||
if text.contains('<') {
|
||||
continue;
|
||||
}
|
||||
let path = text.trim().replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'");
|
||||
if !path.is_empty() {
|
||||
out.push(Entry { include: name == "include", prefix: attribute(tag, "prefix").unwrap_or_default().to_string(), path });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The value of attribute `name` in a start tag's text.
|
||||
fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let mut rest = tag;
|
||||
while let Some(at) = rest.find(name) {
|
||||
let before_ok = rest.get(..at).is_some_and(|b| b.ends_with(char::is_whitespace));
|
||||
rest = rest.get(at + name.len()..)?;
|
||||
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
|
||||
let value = value.trim_start();
|
||||
let quote = value.chars().next().filter(|q| matches!(q, '"' | '\''))?;
|
||||
let (value, _) = value.get(1..)?.split_once(quote)?;
|
||||
if before_ok {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the user's files are: the home folder and the XDG data and configuration folders.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Places {
|
||||
pub home: Option<PathBuf>,
|
||||
pub data_home: Option<PathBuf>,
|
||||
pub config_home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Places {
|
||||
/// The path an entry of the file `from` names: `~` is the home folder, `prefix="xdg"` the XDG
|
||||
/// data folder (a `<dir>`) or configuration folder (an `<include>`), `prefix="relative"` the
|
||||
/// file's own folder, as is an `<include>` that isn't a full path. A `<dir>` relative to the
|
||||
/// folder the app was started in is none.
|
||||
fn resolve(&self, e: &Entry, from: &Path) -> Option<PathBuf> {
|
||||
// `~` alone or before a `/`: another user's home (`~name`) isn't looked up.
|
||||
if let Some(rest) = e.path.strip_prefix('~').filter(|rest| rest.is_empty() || rest.starts_with('/')) {
|
||||
return Some(self.home.as_ref()?.join(rest.trim_start_matches('/')));
|
||||
}
|
||||
let path = Path::new(&e.path);
|
||||
let base = match e.prefix.as_str() {
|
||||
"xdg" if e.include => self.config_home.as_deref(),
|
||||
"xdg" => self.data_home.as_deref(),
|
||||
_ if path.is_absolute() => return Some(path.to_path_buf()),
|
||||
"relative" => from.parent(),
|
||||
_ if e.include => from.parent(),
|
||||
_ => None,
|
||||
};
|
||||
Some(base?.join(path)).filter(|p| p.is_absolute())
|
||||
}
|
||||
}
|
||||
|
||||
/// The font folders the configuration file `root` and the files it includes list, in order, each
|
||||
/// once. Files that are missing or can't be read list none.
|
||||
pub(crate) fn font_dirs(root: &Path, places: &Places) -> Vec<PathBuf> {
|
||||
let mut read = Reader { places, files: HashSet::new(), dirs: vec![] };
|
||||
read.file(root, 0);
|
||||
read.dirs
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
places: &'a Places,
|
||||
/// The files read so far (a file including itself is read once).
|
||||
files: HashSet<PathBuf>,
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn file(&mut self, path: &Path, depth: usize) {
|
||||
if depth > MAX_DEPTH || self.files.len() >= MAX_FILES || !self.files.insert(path.to_path_buf()) {
|
||||
return;
|
||||
}
|
||||
// A folder of configuration files: those named as fontconfig reads them (a digit first,
|
||||
// `.conf` last), in name order.
|
||||
if let Ok(list) = std::fs::read_dir(path) {
|
||||
let mut files: Vec<PathBuf> = list
|
||||
.flatten()
|
||||
.take(MAX_FILES)
|
||||
.filter(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with(|c: char| c.is_ascii_digit()) && name.ends_with(".conf")
|
||||
})
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
self.file(&f, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A file that is missing, too large or not text lists nothing.
|
||||
if !std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() <= MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
for e in entries(&text) {
|
||||
let Some(to) = self.places.resolve(&e, path) else { continue };
|
||||
if e.include {
|
||||
self.file(&to, depth + 1);
|
||||
} else if self.dirs.len() < MAX_DIRS && !self.dirs.contains(&to) {
|
||||
self.dirs.push(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration file fontconfig starts from: `FONTCONFIG_FILE`, else `fonts.conf` in
|
||||
/// `FONTCONFIG_PATH`, else `/etc/fonts/fonts.conf`.
|
||||
pub(crate) fn root() -> PathBuf {
|
||||
let var = |name| std::env::var_os(name).map(PathBuf::from).filter(|p| p.is_absolute());
|
||||
var("FONTCONFIG_FILE").or_else(|| var("FONTCONFIG_PATH").map(|d| d.join("fonts.conf"))).unwrap_or_else(|| "/etc/fonts/fonts.conf".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(include: bool, prefix: &str, path: &str) -> Entry {
|
||||
Entry { include, prefix: prefix.into(), path: path.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirs_and_includes_are_read_and_the_rest_is_skipped() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<description>Default configuration file</description>
|
||||
<!-- <dir>/commented/out</dir> -->
|
||||
<dir>/usr/share/fonts</dir>
|
||||
<dir prefix="xdg">fonts</dir>
|
||||
<dir prefix = 'relative' salt="x"> more & more </dir>
|
||||
<dir>~/.fonts</dir>
|
||||
<dir></dir>
|
||||
<cachedir>/var/cache/fontconfig</cachedir>
|
||||
<match target="pattern"><test qual="any" name="family"><string>mono</string></test></match>
|
||||
<include ignore_missing="yes">conf.d</include>
|
||||
<include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include>
|
||||
<dir>/cut/off"#;
|
||||
assert_eq!(
|
||||
entries(xml),
|
||||
[
|
||||
entry(false, "", "/usr/share/fonts"),
|
||||
entry(false, "xdg", "fonts"),
|
||||
entry(false, "relative", "more & more"),
|
||||
entry(false, "", "~/.fonts"),
|
||||
entry(true, "", "conf.d"),
|
||||
entry(true, "xdg", "fontconfig/fonts.conf"),
|
||||
]
|
||||
);
|
||||
// Damaged files and files that aren't XML read as nothing, without a panic.
|
||||
for junk in
|
||||
["", "<", "<dir", "<dir>", "<dir><dir></dir>", "</dir>", "<!--", "<dir prefix=>x</dir", "\u{0}<dir prefix=\"", "<dir prefix='é>ü</dir>"]
|
||||
{
|
||||
let _ = entries(junk);
|
||||
}
|
||||
assert_eq!(attribute("dir notprefix=\"a\" prefix=\"b\"", "prefix"), Some("b"));
|
||||
}
|
||||
|
||||
/// A configuration as distributions lay it out: the main file lists the usual folders and
|
||||
/// includes `conf.d`, where a package's file adds its own folder (TeX fonts, #1529) and
|
||||
/// another includes the user's files.
|
||||
#[test]
|
||||
fn folders_of_included_files_are_listed_once_in_order() {
|
||||
let tmp = std::env::temp_dir().join(format!("vc-fontconfig-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let (etc, home) = (tmp.join("etc/fonts"), tmp.join("home"));
|
||||
let write = |path: PathBuf, body: &str| {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, format!("<fontconfig>{body}</fontconfig>")).unwrap();
|
||||
};
|
||||
let texmf = tmp.join("usr/share/texmf/fonts/opentype");
|
||||
write(
|
||||
etc.join("fonts.conf"),
|
||||
&format!(
|
||||
"<dir>{}</dir><dir prefix=\"xdg\">fonts</dir><dir>~/.fonts</dir><dir>fonts-of-the-working-folder</dir>\
|
||||
<include ignore_missing=\"yes\">conf.d</include><include>{}</include><include>fonts.conf</include>",
|
||||
tmp.join("usr/share/fonts").display(),
|
||||
etc.join("missing.conf").display()
|
||||
),
|
||||
);
|
||||
write(etc.join("conf.d/09-texlive.conf"), &format!("<dir>{}</dir>", texmf.display()));
|
||||
write(etc.join("conf.d/50-user.conf"), "<include ignore_missing=\"yes\" prefix=\"xdg\">fontconfig/fonts.conf</include>");
|
||||
write(etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
|
||||
write(etc.join("conf.d/user.conf"), "<dir>/no/digit/first</dir>");
|
||||
write(home.join(".config/fontconfig/fonts.conf"), &format!("<dir prefix=\"relative\">mine</dir><dir>{}</dir>", texmf.display()));
|
||||
let places = Places { home: Some(home.clone()), data_home: Some(home.join(".local/share")), config_home: Some(home.join(".config")) };
|
||||
assert_eq!(
|
||||
font_dirs(&etc.join("fonts.conf"), &places),
|
||||
[tmp.join("usr/share/fonts"), home.join(".local/share/fonts"), home.join(".fonts"), texmf, home.join(".config/fontconfig/mine")]
|
||||
);
|
||||
// Without a home folder, what needs one is left out; a missing file lists nothing.
|
||||
assert_eq!(font_dirs(&etc.join("fonts.conf"), &Places::default()).len(), 2);
|
||||
assert!(font_dirs(&tmp.join("none.conf"), &places).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
@@ -1269,12 +1269,21 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
// list their own data folders), relative paths ignored as the XDG spec says.
|
||||
let xdg = |var| std::env::var_os(var).map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let data_home = xdg("XDG_DATA_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".local/share")));
|
||||
dirs.extend(data_home.into_iter().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
|
||||
dirs.extend(data_home.iter().cloned().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
|
||||
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
|
||||
// The folders fontconfig is set up with outside those: packages add their own (TeX fonts
|
||||
// under `/usr/share/texmf/fonts`, #1529), and `fc-list` and other apps show their fonts.
|
||||
let config_home = xdg("XDG_CONFIG_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".config")));
|
||||
let places = crate::fontconfig::Places { home: home.clone(), data_home, config_home };
|
||||
for dir in crate::fontconfig::font_dirs(&crate::fontconfig::root(), &places) {
|
||||
if !dirs.iter().any(|known| dir.starts_with(known)) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs.extend(app_font_dir().map(Path::to_path_buf));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod craft_fonts;
|
||||
pub mod edit;
|
||||
pub mod embed;
|
||||
mod features;
|
||||
mod fontconfig;
|
||||
mod fontdb;
|
||||
pub mod hyphen;
|
||||
mod layout;
|
||||
@@ -25,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 {
|
||||
|
||||
@@ -384,6 +384,12 @@ impl Targets {
|
||||
self
|
||||
}
|
||||
|
||||
/// The ruler guides alone ([`Self::with_rulers`]): what a dragged selection's grabbed point
|
||||
/// still snaps to with View → Snap to Point when Smart Guides line the selection up.
|
||||
pub(crate) fn rulers(cx: &ToolContext) -> Self {
|
||||
Self::default().styled(cx).with_rulers(cx)
|
||||
}
|
||||
|
||||
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
|
||||
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
|
||||
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
|
||||
@@ -434,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());
|
||||
}
|
||||
@@ -539,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>) {
|
||||
@@ -822,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))
|
||||
@@ -1374,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() {
|
||||
|
||||
@@ -90,6 +90,9 @@ pub(crate) struct MoveSnap {
|
||||
/// original.
|
||||
copy_targets: Option<Targets>,
|
||||
copy_points: Option<Targets>,
|
||||
/// With Smart Guides on, the ruler guides View › Snap to Point still pulls the grabbed point
|
||||
/// onto (#1534); `points` holds them with Smart Guides off.
|
||||
rulers: Option<Targets>,
|
||||
}
|
||||
|
||||
impl MoveSnap {
|
||||
@@ -102,6 +105,7 @@ impl MoveSnap {
|
||||
points: points(&cx.selection.objects),
|
||||
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
|
||||
copy_points: points(&[]),
|
||||
rulers: (guides && cx.snap_to_point).then(|| Targets::rulers(cx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +137,14 @@ impl MoveSnap {
|
||||
guides = ov;
|
||||
}
|
||||
}
|
||||
// A ruler guide within reach of the grabbed point wins over lining the bounds up.
|
||||
if let Some(t) = &self.rulers {
|
||||
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
|
||||
if q != start + d {
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
}
|
||||
}
|
||||
if cx.snap_to_pixel
|
||||
&& let Some(b) = self.bounds
|
||||
{
|
||||
@@ -733,6 +745,29 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps
|
||||
/// to a ruler guide within the Snap to Point distance; not with Snap to Point off.
|
||||
#[test]
|
||||
fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)];
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let dx = |c: &ToolContext, to: f64| {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0));
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
v["matrix"][4].as_f64().unwrap()
|
||||
};
|
||||
let c = cx(&d, &s, &p);
|
||||
assert!(c.smart_guides && c.snap_to_point);
|
||||
assert!((dx(&c, 172.0) - 23.4).abs() < 1e-9, "onto the guide");
|
||||
assert_eq!(dx(&c, 165.0), 15.0, "out of reach");
|
||||
assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0);
|
||||
}
|
||||
|
||||
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
|
||||
/// gathered before the previews moved it); released, the move doesn't.
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -12,14 +12,23 @@
|
||||
//! merged into one point) become polylines through the pixel centres; rings without ends
|
||||
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
|
||||
//! on either side of a junction left with two of them are joined.
|
||||
//! 5. **Curves**: each polyline is fitted like an outline ([`fit::fit_polyline`]); the stroke
|
||||
//! width is the line's mean width (its pixel count over its length).
|
||||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||||
//! cap adds.
|
||||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::fit::{self, FitOptions};
|
||||
use crate::Raster;
|
||||
use crate::fit;
|
||||
use crate::logo::Style;
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
@@ -31,7 +40,19 @@ pub(crate) struct Line {
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f64, o: &FitOptions) -> (Vec<bool>, Vec<Line>) {
|
||||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||||
/// image's own black), which the lines' middles and widths are read against.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lines(
|
||||
labels: &[u16],
|
||||
label: u16,
|
||||
w: usize,
|
||||
h: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut thin = vec![false; labels.len()];
|
||||
let mut out = vec![];
|
||||
if w == 0 || h == 0 || labels.len() != w * h {
|
||||
@@ -61,7 +82,7 @@ pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(&comp, w, max_width, o) else { continue };
|
||||
let Some(line) = trace_line(&comp, w, max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
thin[i] = true;
|
||||
}
|
||||
@@ -221,7 +242,7 @@ impl Run {
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Option<Line> {
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
let xs = comp.iter().map(|&i| i % w);
|
||||
let ys = comp.iter().map(|&i| i / w);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
@@ -312,14 +333,168 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Optio
|
||||
};
|
||||
let spur = width(&runs);
|
||||
let runs = join(prune(runs, centres.len(), spur), centres.len());
|
||||
let width = width(&runs);
|
||||
let subs: Vec<_> = runs.iter().filter_map(|r| fit::fit_polyline(&r.pts, r.closed, o)).collect();
|
||||
let mut runs = runs;
|
||||
let coarse = width(&runs);
|
||||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||||
// How many runs end at each node: one is an end of the line.
|
||||
let mut meet = vec![0usize; centres.len()];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
meet[*e] += 1;
|
||||
}
|
||||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||||
for r in &mut runs {
|
||||
let n = r.pts.len();
|
||||
let old = r.pts.clone();
|
||||
for i in 0..n {
|
||||
// A junction stays where its pixels put it.
|
||||
let end = if i == 0 {
|
||||
r.ends[0]
|
||||
} else if i + 1 == n {
|
||||
r.ends[1]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if end.is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = if r.closed { (old[(i + n - 2 % n) % n], old[(i + 2) % n]) } else { (old[i.saturating_sub(2)], old[(i + 2).min(n - 1)]) };
|
||||
let d = b - a;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let upright = d.x.abs() >= d.y.abs();
|
||||
let Some(across) = ink.across(old[i], upright) else { continue };
|
||||
r.pts[i] = old[i] + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||||
read += 1;
|
||||
soft += usize::from(across.soft);
|
||||
}
|
||||
}
|
||||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its round cap adds.
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends[k].is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let (tip, inner) = if k == 0 { (r.pts[0], r.pts[3.min(n - 1)]) } else { (r.pts[n - 1], r.pts[n - 1 - 3.min(n - 1)]) };
|
||||
let d = tip - inner;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let more = ink.beyond(tip, d / d.hypot()) - width / 2.0;
|
||||
if more.abs() > 0.05 && more > -1.0 {
|
||||
let to = tip + d / d.hypot() * more;
|
||||
if k == 0 { r.pts[0] = to } else { r.pts[n - 1] = to }
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = *style;
|
||||
if read > 0 && soft * 4 < read {
|
||||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 2.5;
|
||||
style.sigma = 1.5;
|
||||
}
|
||||
let subs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||||
})
|
||||
.collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
struct Ink<'a> {
|
||||
img: &'a Raster,
|
||||
/// The line's colour.
|
||||
color: [f64; 3],
|
||||
/// How far (pixels) from the skeleton the ink is looked for.
|
||||
reach: i64,
|
||||
}
|
||||
|
||||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||||
struct Across {
|
||||
middle: f64,
|
||||
width: f64,
|
||||
soft: bool,
|
||||
}
|
||||
|
||||
impl Ink<'_> {
|
||||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
let at = (y * w + x) as usize * 4;
|
||||
let p = self.img.rgba.get(at..at + 3)?;
|
||||
Some((0..3).map(|k| (f64::from(p[k]) - self.color[k]).powi(2)).sum::<f64>().sqrt())
|
||||
}
|
||||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||||
/// within reach, along its row and its column.
|
||||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||||
}
|
||||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
let paper = self.paper(x, y);
|
||||
// Paper too like the ink says nothing.
|
||||
if paper < 24.0 {
|
||||
return None;
|
||||
}
|
||||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||||
let here = tone(peak);
|
||||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||||
for side in [-1, 1] {
|
||||
let mut last = here;
|
||||
for k in 1..=self.reach {
|
||||
let t = tone(peak + side * k);
|
||||
if t < 0.04 || t > last + 0.1 {
|
||||
break;
|
||||
}
|
||||
sum += t;
|
||||
moment += t * (peak + side * k) as f64;
|
||||
soft |= t > 0.1 && t < 0.9;
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||||
}
|
||||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||||
if paper < 24.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for step in 1..=4 * (self.reach + 2) {
|
||||
let q = tip + dir * (step as f64 * 0.25);
|
||||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
if t < 0.04 {
|
||||
break;
|
||||
}
|
||||
sum += 0.25 * t;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
|
||||
/// to a junction that keeps at least two other runs.
|
||||
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
|
||||
|
||||
@@ -51,7 +51,8 @@ fn step(d: u8) -> (i32, i32) {
|
||||
fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
|
||||
let mut comp = vec![u32::MAX; w * h];
|
||||
let mut sizes = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
// (Indices as `u32`: a mask has at most 64 million pixels, and this stack can hold them all.)
|
||||
let mut stack: Vec<u32> = Vec::new();
|
||||
for s in 0..w * h {
|
||||
if !mask[s] || comp[s] != u32::MAX {
|
||||
continue;
|
||||
@@ -59,14 +60,15 @@ fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
|
||||
let id = sizes.len() as u32;
|
||||
let mut n = 0usize;
|
||||
comp[s] = id;
|
||||
stack.push(s);
|
||||
stack.push(s as u32);
|
||||
while let Some(i) = stack.pop() {
|
||||
let i = i as usize;
|
||||
n += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut go = |j: usize| {
|
||||
if mask[j] && comp[j] == u32::MAX {
|
||||
comp[j] = id;
|
||||
stack.push(j);
|
||||
stack.push(j as u32);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
|
||||
+96
-87
@@ -1,20 +1,11 @@
|
||||
//! Loop → Bézier curves, and centre line → Bézier curves.
|
||||
//! Loop → Bézier curves, and the pieces of that which centre lines use too.
|
||||
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, CubicBez, ParamCurve, PathData, Point, SubPath};
|
||||
use vectorcraft_pathops::{SimplifyOptions, simplify_with};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath};
|
||||
|
||||
use crate::contour::Loop;
|
||||
use crate::logo::{Cubic, Stats, Style, all_within, fit_open, fit_outline, resample_closed, resample_open, sample_cubics};
|
||||
use crate::subpixel::{self, Crack};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FitOptions {
|
||||
pub polygon_tol: f64,
|
||||
pub fit_tol: f64,
|
||||
pub corner_angle: f64,
|
||||
pub snap_lines: bool,
|
||||
}
|
||||
|
||||
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.hypot2();
|
||||
@@ -60,10 +51,78 @@ fn before(a: Point, b: Point) -> std::cmp::Ordering {
|
||||
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
|
||||
}
|
||||
|
||||
/// 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 (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) {
|
||||
let n = segs.len();
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
let lines: Vec<bool> = segs.iter().map(straight).collect();
|
||||
// Anchor `i` starts piece `i`; an open chain has one more, at its end.
|
||||
let fixed = |i: usize| !closed && fixed_ends && (i == 0 || i == n);
|
||||
let at = |segs: &[Cubic], i: usize| if i < n { segs[i][0] } else { segs[n - 1][3] };
|
||||
let put = |segs: &mut [Cubic], i: usize, p: Point| {
|
||||
let i = if closed { i % n } else { i };
|
||||
let d = p - at(segs, i);
|
||||
if i < n {
|
||||
segs[i][0] = p;
|
||||
segs[i][1] += d;
|
||||
}
|
||||
if let Some(before) = if i > 0 { Some(i - 1) } else { closed.then_some(n - 1) } {
|
||||
segs[before][3] = p;
|
||||
segs[before][2] += d;
|
||||
}
|
||||
};
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
let (ia, ib) = (i, if closed { (i + 1) % n } else { i + 1 });
|
||||
let (a, b) = (at(segs, ia), at(segs, ib));
|
||||
let d = b - a;
|
||||
if !line || d.hypot() < 2.0 || ia == ib {
|
||||
continue;
|
||||
}
|
||||
let level = d.y.abs() <= NEARLY_UPRIGHT * d.x.abs();
|
||||
if !level && d.x.abs() > NEARLY_UPRIGHT * d.y.abs() {
|
||||
continue;
|
||||
}
|
||||
let pick = |p: Point| if level { p.y } else { p.x };
|
||||
let to = match (fixed(ia), fixed(ib)) {
|
||||
(true, true) => continue,
|
||||
(true, false) => pick(a),
|
||||
(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));
|
||||
}
|
||||
if !fixed(ib) {
|
||||
put(segs, ib, moved(b));
|
||||
}
|
||||
}
|
||||
// A straight piece has its handles on it.
|
||||
for (c, _) in segs.iter_mut().zip(&lines).filter(|(_, line)| **line) {
|
||||
*c = [c[0], c[0] + (c[3] - c[0]) / 3.0, c[0] + (c[3] - c[0]) * (2.0 / 3.0), c[3]];
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole loop fitted. The same loop gives the same curves whichever way it runs and wherever it
|
||||
/// starts (it is fitted clockwise from its leftmost point): the outline of a shape and the hole it
|
||||
/// leaves in the shape around it are one curve.
|
||||
fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
|
||||
pub(crate) fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
|
||||
let n = pts.len();
|
||||
let area2: f64 = (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum();
|
||||
let flip = area2 < 0.0;
|
||||
@@ -80,13 +139,17 @@ fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
|
||||
if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
|
||||
segs = polygon(&pts, style.tol);
|
||||
}
|
||||
if style.upright {
|
||||
snap_upright(&mut segs, true, false);
|
||||
}
|
||||
Some(if flip { reversed(segs) } else { segs })
|
||||
}
|
||||
|
||||
/// A piece of a loop between two junctions fitted, from its first point to its last. The same
|
||||
/// piece gives the same curve whichever way it runs (it is fitted from its end further left): the
|
||||
/// edge two regions share is one curve.
|
||||
fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
|
||||
/// edge two regions share is one curve. `fixed_ends`: its ends are junctions and stay put (a centre
|
||||
/// line's ends are its own).
|
||||
pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec<Cubic> {
|
||||
let n = pts.len();
|
||||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return vec![] };
|
||||
let flip = match before(first, last) {
|
||||
@@ -107,6 +170,9 @@ fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
|
||||
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
segs = kept.windows(2).filter(|p| p[0] != p[1]).map(|p| [p[0], p[0], p[1], p[1]]).collect();
|
||||
}
|
||||
if style.upright {
|
||||
snap_upright(&mut segs, false, fixed_ends);
|
||||
}
|
||||
if flip { reversed(segs) } else { segs }
|
||||
}
|
||||
|
||||
@@ -137,22 +203,30 @@ pub(crate) fn fit_loop(
|
||||
if piece.closed {
|
||||
segs = fit_closed(&piece.pts, &style)?;
|
||||
} else {
|
||||
segs.extend(fit_piece(&piece.pts, &style));
|
||||
segs.extend(fit_piece(&piece.pts, &style, true));
|
||||
}
|
||||
}
|
||||
subpath(&segs, true)
|
||||
}
|
||||
|
||||
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
|
||||
/// no handles; an anchor whose two handles line up is smooth.
|
||||
pub(crate) fn subpath(segs: &[Cubic], closed: bool) -> Option<SubPath> {
|
||||
let n = segs.len();
|
||||
// A straight piece has no handles.
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
let mut anchors = Vec::with_capacity(n);
|
||||
for (i, cur) in segs.iter().enumerate() {
|
||||
let before = segs.get((i + n - 1) % n)?;
|
||||
let p = cur[0];
|
||||
let (h_in, h_out) = (if straight(before) { p } else { before[2] }, if straight(cur) { p } else { cur[1] });
|
||||
let mut anchors = Vec::with_capacity(n + 1);
|
||||
for i in 0..if closed { n } else { n + 1 } {
|
||||
// The piece that arrives at this anchor and the one that leaves it.
|
||||
let before = if i > 0 { segs.get(i - 1) } else { closed.then(|| segs.last()).flatten() };
|
||||
let after = segs.get(i);
|
||||
let p = after.map(|c| c[0]).or(before.map(|c| c[3]))?;
|
||||
let h_in = before.filter(|c| !straight(c)).map_or(p, |c| c[2]);
|
||||
let h_out = after.filter(|c| !straight(c)).map_or(p, |c| c[1]);
|
||||
let (vi, vo) = (p - h_in, h_out - p);
|
||||
let smooth = vi.hypot() > 1e-9 && vo.hypot() > 1e-9 && vi.cross(vo).abs() < 0.07 * vi.hypot() * vo.hypot() && vi.dot(vo) > 0.0;
|
||||
anchors.push(Anchor { p, h_in, h_out, kind: if smooth { AnchorKind::Smooth } else { AnchorKind::Corner } });
|
||||
}
|
||||
(n >= 2).then(|| SubPath::new(anchors, true))
|
||||
(n >= if closed { 2 } else { 1 }).then(|| SubPath::new(anchors, closed))
|
||||
}
|
||||
|
||||
/// The closed outline `pts` as straight pieces within `tol` of it (Douglas–Peucker).
|
||||
@@ -170,68 +244,3 @@ fn polygon(pts: &[Point], tol: f64) -> Vec<Cubic> {
|
||||
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
(0..kept.len()).map(|i| [kept[i], kept[i], kept[(i + 1) % kept.len()], kept[(i + 1) % kept.len()]]).collect()
|
||||
}
|
||||
|
||||
/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the
|
||||
/// points within `polygon_tol` of it are dropped (Douglas–Peucker), then curves are fitted. A
|
||||
/// `closed` line runs back to its first point.
|
||||
pub(crate) fn fit_polyline(pts: &[Point], closed: bool, o: &FitOptions) -> Option<SubPath> {
|
||||
let n = pts.len();
|
||||
let first = *pts.first()?;
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut ext = pts.to_vec();
|
||||
let mut keep = vec![false; n + 1];
|
||||
keep[0] = true;
|
||||
if closed {
|
||||
// Split the ring at its first point and the point farthest from it.
|
||||
ext.push(first);
|
||||
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(n / 2);
|
||||
keep[far] = true;
|
||||
rdp(&ext, 0, far, o.polygon_tol, &mut keep);
|
||||
rdp(&ext, far, n, o.polygon_tol, &mut keep);
|
||||
} else {
|
||||
keep[n - 1] = true;
|
||||
rdp(&ext, 0, n - 1, o.polygon_tol, &mut keep);
|
||||
}
|
||||
let mut poly: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
if closed && poly.len() < 3 {
|
||||
poly = pts.to_vec();
|
||||
}
|
||||
if poly.len() < if closed { 3 } else { 2 } {
|
||||
return None;
|
||||
}
|
||||
let sp = SubPath::polyline(&poly, closed);
|
||||
let fitted =
|
||||
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
|
||||
let mut sp = fitted.subpaths.into_iter().next()?;
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if o.snap_lines {
|
||||
snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5));
|
||||
}
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// Retract the handles of curves that stay within `tol` of their chord.
|
||||
fn snap_to_lines(sp: &mut SubPath, tol: f64) {
|
||||
let n = sp.anchors.len();
|
||||
for i in 0..sp.segment_count() {
|
||||
let j = (i + 1) % n;
|
||||
let (a, b) = (sp.anchors[i], sp.anchors[j]);
|
||||
if !a.has_out() && !b.has_in() {
|
||||
continue;
|
||||
}
|
||||
let c = CubicBez::new(a.p, a.h_out, b.h_in, b.p);
|
||||
if (1..8).all(|k| seg_dist(c.eval(k as f64 / 8.0), a.p, b.p) <= tol) {
|
||||
sp.anchors[i].h_out = a.p;
|
||||
sp.anchors[j].h_in = b.p;
|
||||
}
|
||||
}
|
||||
for a in &mut sp.anchors {
|
||||
if !a.has_in() || !a.has_out() {
|
||||
a.kind = AnchorKind::Corner;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user