mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-20 14:14:17 -04:00
Compare commits
347
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0566943a | ||
|
|
9e7929ee89 | ||
|
|
2e4666a364 | ||
|
|
ee4564c586 | ||
|
|
c9d769ad4e | ||
|
|
62e8474caa | ||
|
|
301783c480 | ||
|
|
64699f5212 | ||
|
|
bea46e63ff | ||
|
|
c755909d67 | ||
|
|
30f372a220 | ||
|
|
24936cd006 | ||
|
|
1dda8d9e67 | ||
|
|
20557277c2 | ||
|
|
050f68a030 | ||
|
|
39fdaf7db8 | ||
|
|
2544d7f090 | ||
|
|
fd69aa0623 | ||
|
|
5dd86dc19c | ||
|
|
7617fcd536 | ||
|
|
c3083e245d | ||
|
|
0688ad04d7 | ||
|
|
89986d01bf | ||
|
|
4cd77a7a18 | ||
|
|
19efa9ea2b | ||
|
|
371d0de798 | ||
|
|
548ace24d2 | ||
|
|
a13968e821 | ||
|
|
e2e2ac12a9 | ||
|
|
61f35cd037 | ||
|
|
891793b3a4 | ||
|
|
6b24e3cd6a | ||
|
|
02e12c6935 | ||
|
|
f664564e97 | ||
|
|
d14b67e015 | ||
|
|
5251246778 | ||
|
|
1097ab190d | ||
|
|
17ba27a0f6 | ||
|
|
4322c825a5 | ||
|
|
ddab0152af | ||
|
|
f12ad3aa0a | ||
|
|
699e6e9af4 | ||
|
|
059b6d083c | ||
|
|
7852f363a1 | ||
|
|
263c36567f | ||
|
|
398d2e19de | ||
|
|
0567752448 | ||
|
|
38e73060a7 | ||
|
|
6bed87b5fb | ||
|
|
6f3c974984 | ||
|
|
cade497b79 | ||
|
|
feda1c062f | ||
|
|
b929561fc3 | ||
|
|
4be63905b1 | ||
|
|
8a7a1cf199 | ||
|
|
b560b97616 | ||
|
|
d21f3e0667 | ||
|
|
ac7f0eebf8 | ||
|
|
498d17f72e | ||
|
|
326b1c3139 | ||
|
|
2ff683d5fe | ||
|
|
fd780c51b0 | ||
|
|
1b6552a4fa | ||
|
|
8918747a24 | ||
|
|
ef99948cd5 | ||
|
|
04139ad0b6 | ||
|
|
0f855be77f | ||
|
|
7c47692b32 | ||
|
|
8a6e4e77f3 | ||
|
|
686da2dce7 | ||
|
|
68b1f7a07c | ||
|
|
2ceb183bfe | ||
|
|
fe6ef8ade7 | ||
|
|
05017111ba | ||
|
|
5397ad22e7 | ||
|
|
a5ec99d515 | ||
|
|
92c1657334 | ||
|
|
8f481aa2f4 | ||
|
|
7616bb9221 | ||
|
|
1f579352ed | ||
|
|
851eb7f076 | ||
|
|
a2c764d312 | ||
|
|
917aa29dd5 | ||
|
|
11adb72d44 | ||
|
|
52380d683d | ||
|
|
1f2007eb46 | ||
|
|
19e3144d27 | ||
|
|
cfea906681 | ||
|
|
fbc654734f | ||
|
|
ac83e91c5f | ||
|
|
10e59b0d13 | ||
|
|
dc79640956 | ||
|
|
e2b2c410d9 | ||
|
|
30646c83bd | ||
|
|
361ebd29bf | ||
|
|
39daf465f5 | ||
|
|
d8b0dcfc04 | ||
|
|
d5cd54c9b8 | ||
|
|
443ffd6c6f | ||
|
|
73a3347bbb | ||
|
|
5c32d527f1 | ||
|
|
1abe1961ca | ||
|
|
f922a635cb | ||
|
|
91fde1d7fa | ||
|
|
6a8d52804b | ||
|
|
8521e0a505 | ||
|
|
e96771d5ea | ||
|
|
223bfd29c2 | ||
|
|
12f7d41c38 | ||
|
|
85163a7c82 | ||
|
|
1c1fd57b33 | ||
|
|
88a444ef7b | ||
|
|
0c62d9c02c | ||
|
|
cb229ac08c | ||
|
|
b8f9c403a7 | ||
|
|
fd53416de3 | ||
|
|
d507a73d09 | ||
|
|
9435c5079d | ||
|
|
99aa4e6a7c | ||
|
|
0ee6b1ac68 | ||
|
|
b21b3edb6c | ||
|
|
88bd9bcd9e | ||
|
|
6ef9f2dd93 | ||
|
|
6d713de980 | ||
|
|
1eac4f5b79 | ||
|
|
a2aac6bead | ||
|
|
222ec72e28 | ||
|
|
8f7486b90f | ||
|
|
5c5684f4c5 | ||
|
|
19adc36964 | ||
|
|
f412d30adf | ||
|
|
7bf326eaf1 | ||
|
|
bf021ae08b | ||
|
|
301727c7d5 | ||
|
|
a52198cefa | ||
|
|
926ca62952 | ||
|
|
d3ee9646de | ||
|
|
a446d76637 | ||
|
|
81cf399bfa | ||
|
|
d8c20c8eb4 | ||
|
|
f106f32af4 | ||
|
|
acd8ca097d | ||
|
|
f9e559156f | ||
|
|
c2a605ce8e | ||
|
|
ec4975f237 | ||
|
|
cb631ae635 | ||
|
|
5087186eac | ||
|
|
84ab208c43 | ||
|
|
ae617db6d3 | ||
|
|
408081000d | ||
|
|
34a9a48340 | ||
|
|
6de7c0a441 | ||
|
|
b48290a4f9 | ||
|
|
7833756f6e | ||
|
|
ce998b89bc | ||
|
|
db92c91678 | ||
|
|
ddc5fda173 | ||
|
|
bb32072be9 | ||
|
|
d41af02637 | ||
|
|
62910d0327 | ||
|
|
db9fa8e0a9 | ||
|
|
6d7a062227 | ||
|
|
4b8e42e517 | ||
|
|
5bf159d5d1 | ||
|
|
a48e65ba68 | ||
|
|
f557a1b6f3 | ||
|
|
5af05ab572 | ||
|
|
197cf0ba67 | ||
|
|
0734836616 | ||
|
|
de8492b90c | ||
|
|
e975942be0 | ||
|
|
2f93923e60 | ||
|
|
68a99c359b | ||
|
|
1a1b0dfd59 | ||
|
|
472a6d5359 | ||
|
|
819da290f1 | ||
|
|
303c5f8bca | ||
|
|
de985a46e6 | ||
|
|
035f1cb2f6 | ||
|
|
e52b07105d | ||
|
|
00f1231334 | ||
|
|
c58c642f07 | ||
|
|
39b59b5730 | ||
|
|
2c6a2ad082 | ||
|
|
b09e23f4a1 | ||
|
|
1e914c8d82 | ||
|
|
45d9a409d4 | ||
|
|
9d4675e7d4 | ||
|
|
d5dfbf4bc3 | ||
|
|
af48dda322 | ||
|
|
b87cb157ed | ||
|
|
d2b927b57b | ||
|
|
04750ece1b | ||
|
|
c08955320c | ||
|
|
742a5c1af2 | ||
|
|
1f80c28662 | ||
|
|
6fed871f88 | ||
|
|
3033fb9985 | ||
|
|
7e1095f9b1 | ||
|
|
f378c5c836 | ||
|
|
55acd88d42 | ||
|
|
319221c087 | ||
|
|
9d43326ad7 | ||
|
|
8baf5b1343 | ||
|
|
a60f8d7b9f | ||
|
|
2c52bed0b5 | ||
|
|
de3dd27fda | ||
|
|
575bd0f924 | ||
|
|
1ef66c48c1 | ||
|
|
35b5895183 | ||
|
|
0bd2a40128 | ||
|
|
605597b09c | ||
|
|
646563b033 | ||
|
|
2861c113d6 | ||
|
|
48a0206adb | ||
|
|
1fc3c23096 | ||
|
|
7dc0c034d8 | ||
|
|
be7fd07562 | ||
|
|
007f536c79 | ||
|
|
515569470c | ||
|
|
c07945b934 | ||
|
|
de4c6b1a6d | ||
|
|
ecaae7ee46 | ||
|
|
7bc7025a62 | ||
|
|
8d123b19ca | ||
|
|
4aa4b0e70b | ||
|
|
4614c08476 | ||
|
|
2ecc488111 | ||
|
|
6ed171c1b4 | ||
|
|
3eed3be8e6 | ||
|
|
85af04a83a | ||
|
|
bdd5470dcf | ||
|
|
157679c1a8 | ||
|
|
320bffb8ad | ||
|
|
3d23fc5175 | ||
|
|
8c6085beca | ||
|
|
6cb075b926 | ||
|
|
0f27503f5b | ||
|
|
92ca32e55d | ||
|
|
37ee8bee3c | ||
|
|
219253f163 | ||
|
|
e6c8ce1759 | ||
|
|
e1761eb489 | ||
|
|
7f01abd687 | ||
|
|
729582dc78 | ||
|
|
7c64ecd998 | ||
|
|
55f98095f3 | ||
|
|
ac1b380b40 | ||
|
|
c3a6aa0a29 | ||
|
|
1d214956f9 | ||
|
|
32a322531a | ||
|
|
0d09d6a188 | ||
|
|
73eaa84d34 | ||
|
|
a466625c89 | ||
|
|
73f921fa0a | ||
|
|
fb18533e12 | ||
|
|
56be31fe9b | ||
|
|
a23d7a827f | ||
|
|
1c80b23745 | ||
|
|
c9a2491ef7 | ||
|
|
2ae992870b | ||
|
|
cec31c5f4a | ||
|
|
3b527ca339 | ||
|
|
4f18f1c860 | ||
|
|
b529f356cc | ||
|
|
048875cc4e | ||
|
|
57ddb71d97 | ||
|
|
49f7f5d7d8 | ||
|
|
aa9c70a7b1 | ||
|
|
278fba490a | ||
|
|
d807236bf2 | ||
|
|
9c1d1d56ed | ||
|
|
53bd154059 | ||
|
|
1f2e90a7b3 | ||
|
|
c7f748c75c | ||
|
|
4de769175e | ||
|
|
9a8e2ac6a0 | ||
|
|
419ed87cff | ||
|
|
eeb9513bbb | ||
|
|
c092ca1a48 | ||
|
|
ba2a11a807 | ||
|
|
f1757c7bda | ||
|
|
e616f1fe5c | ||
|
|
647d51eaf6 | ||
|
|
0d34a58b7b | ||
|
|
3a685eae61 | ||
|
|
e4d94109a2 | ||
|
|
4a90d13fc0 | ||
|
|
8a2d0db225 | ||
|
|
e358bbfdfb | ||
|
|
7d7853d7fd | ||
|
|
0a14001126 | ||
|
|
910e33b796 | ||
|
|
9b64fce9ec | ||
|
|
cd35c27a53 | ||
|
|
ab01a1a927 | ||
|
|
d054b5b4c7 | ||
|
|
ba32ef5b09 | ||
|
|
f0c2d8c890 | ||
|
|
e5c9054324 | ||
|
|
9fe0f03de2 | ||
|
|
429e147164 | ||
|
|
c0ec14b86d | ||
|
|
89a73abc38 | ||
|
|
cc8944ad2a | ||
|
|
05264b2801 | ||
|
|
372887fc6c | ||
|
|
841fc0cd51 | ||
|
|
d7e7066251 | ||
|
|
dc7216f0e4 | ||
|
|
36aecff81d | ||
|
|
9dbab52168 | ||
|
|
09a6fbe470 | ||
|
|
b30d80e056 | ||
|
|
1b8e3d9ae1 | ||
|
|
12d107cc56 | ||
|
|
eba9abc21e | ||
|
|
88494319da | ||
|
|
d25289a3be | ||
|
|
2025c357d9 | ||
|
|
16dcaa425b | ||
|
|
bf19ada5e1 | ||
|
|
c5798f446e | ||
|
|
4675a1633e | ||
|
|
d99b04e594 | ||
|
|
0f6154e7b9 | ||
|
|
fc594b51ee | ||
|
|
cdba0aac03 | ||
|
|
f44c3e9593 | ||
|
|
c965f49fda | ||
|
|
a10604bbc6 | ||
|
|
4f526b4e3f | ||
|
|
f2601137ab | ||
|
|
9938a31c9b | ||
|
|
5f37fccdce | ||
|
|
e8e88c14f2 | ||
|
|
2cdb0d2d53 | ||
|
|
fe16f852db | ||
|
|
43bb588e5e | ||
|
|
a57eaa0782 | ||
|
|
007df2ee24 | ||
|
|
0955e6845c | ||
|
|
4bafe834a6 | ||
|
|
87603e4c35 | ||
|
|
e60882a3a1 | ||
|
|
25a987a94f | ||
|
|
090659b78b |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/affinity/fuzz"
|
||||
- "/crates/plugins/example"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
run-package-check: true
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
Generated
+41
-28
@@ -3676,6 +3676,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -4512,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4549,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4560,7 +4571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4572,7 +4583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4588,7 +4599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4610,7 +4621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4622,7 +4633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
@@ -4640,7 +4651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4656,7 +4667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
@@ -4694,7 +4705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4717,7 +4728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4732,7 +4743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4744,7 +4755,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4763,7 +4774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4777,7 +4788,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4789,7 +4800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
@@ -4819,7 +4830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4833,7 +4844,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4860,14 +4871,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -4892,7 +4903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -4910,7 +4921,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4930,7 +4941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4938,7 +4949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4954,7 +4965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4966,7 +4977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -5000,13 +5011,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
@@ -5947,6 +5959,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6110,7 +6123,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -65,7 +65,7 @@ wat = "1.228"
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
|
||||
@@ -92,3 +92,34 @@ Assets already bundled through Hayro's default features retain their licences:
|
||||
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
|
||||
Adobe and contributor names do not endorse or promote this product.
|
||||
Per-file hashes and sources are recorded in ASSETS.md.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -11,11 +11,11 @@
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
|
||||
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
A fast, open-source vector illustration workflow. It runs natively on
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Why VectorCraft
|
||||
|
||||
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
|
||||
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
|
||||
full retina resolution while the interface stays at 120 fps.
|
||||
@@ -163,6 +163,8 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Quick start
|
||||
|
||||
For posters and sewing patterns, see [Print a large poster across multiple pages](docs/print-tiling.md).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/vectorcraft
|
||||
cd vectorcraft
|
||||
@@ -178,6 +180,8 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
|
||||
```
|
||||
|
||||
For text and custom letter shapes, see [Typography and Custom Lettering](docs/typography-lettering.md).
|
||||
|
||||
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
@@ -224,9 +228,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
@@ -240,20 +243,20 @@ and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- most of the raster effects (11 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation and Stamp under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
@@ -372,8 +375,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+7
-4
@@ -9,7 +9,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-18 · **Change:** minor (Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; 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
|
||||
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 46/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 81% | 8–14 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
|
||||
5. **Raster effects** (G2) alongside: the 11 remaining filters (7 Artistic, 3 Sketch, 1 more) and the Effect Gallery, 8–14 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
## Progress log
|
||||
@@ -134,7 +134,10 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
|
||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
|
||||
@@ -79,14 +79,16 @@ USAGE:
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
@@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
// 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});
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
@@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", json!({"path": path, "scale": scale}))
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
|
||||
@@ -58,6 +58,28 @@ fn run_batch_exports() {
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_sizes_raster_exports_only() {
|
||||
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
|
||||
// resize the drawing and leave its units unset.
|
||||
let svg = tmp("scale.svg");
|
||||
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
|
||||
.unwrap();
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
|
||||
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
|
||||
// 100 pt at scale 2 → 200 px (IHDR width).
|
||||
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,12 +16,16 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -72,6 +72,9 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std"
|
||||
pollster = "1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# GNOME/Wayland needs client-side window controls without a bundled title font.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
|
||||
# so Automatic draws on that one. Both are already in the build through winit.
|
||||
wayland-client = "0.31"
|
||||
|
||||
@@ -1090,4 +1090,116 @@ mod tests {
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
|
||||
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
|
||||
/// declares.
|
||||
const LINUX_TYPES: &[(&str, &str)] = &[
|
||||
("vectorcraft", "application/x-vectorcraft"),
|
||||
("drawcraft", "application/x-vectorcraft"),
|
||||
("svg", "image/svg+xml"),
|
||||
("svgz", "image/svg+xml-compressed"),
|
||||
("pdf", "application/pdf"),
|
||||
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
|
||||
("ait", "application/x-ai-template"),
|
||||
("png", "image/png"),
|
||||
("jpg", "image/jpeg"),
|
||||
("jpeg", "image/jpeg"),
|
||||
("gif", "image/gif"),
|
||||
("webp", "image/webp"),
|
||||
("tif", "image/tiff"),
|
||||
("tiff", "image/tiff"),
|
||||
("bmp", "image/bmp"),
|
||||
("vctemplate", "application/x-vectorcraft-template"),
|
||||
("dxf", "image/vnd.dxf"),
|
||||
("emf", "image/emf"),
|
||||
("wmf", "image/wmf"),
|
||||
("eps", "image/x-eps"),
|
||||
("af", "application/x-affinity"),
|
||||
("afdesign", "application/x-affinity"),
|
||||
("afpub", "application/x-affinity"),
|
||||
];
|
||||
|
||||
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
|
||||
/// other one.
|
||||
const LINUX_SHARED_TYPES: &[&str] = &[
|
||||
"image/svg+xml",
|
||||
"image/svg+xml-compressed",
|
||||
"application/pdf",
|
||||
"application/illustrator", // brand-ok: shared-mime-info's type
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/vnd.dxf",
|
||||
"image/emf",
|
||||
"image/wmf",
|
||||
"image/x-eps",
|
||||
];
|
||||
|
||||
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
|
||||
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
|
||||
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
|
||||
"af",
|
||||
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
|
||||
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
|
||||
)];
|
||||
|
||||
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
|
||||
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
|
||||
/// have.
|
||||
#[test]
|
||||
fn the_linux_desktop_entry_opens_every_readable_format() {
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
|
||||
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
|
||||
let listed: Vec<&str> =
|
||||
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
|
||||
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
|
||||
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
|
||||
let declared: Vec<(&str, Vec<&str>, String)> = mime
|
||||
.split("<mime-type type=\"")
|
||||
.skip(1)
|
||||
.map(|block| {
|
||||
let body = block.split("</mime-type>").next().unwrap_or_default();
|
||||
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
|
||||
let magic = match (body.find("<magic"), body.find("</magic>")) {
|
||||
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(block.split('"').next().unwrap_or_default(), globs, magic)
|
||||
})
|
||||
.collect();
|
||||
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
|
||||
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
|
||||
associated.sort_unstable();
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
|
||||
for (e, t) in LINUX_TYPES {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
|
||||
let package = declared.iter().find(|(name, ..)| name == t);
|
||||
if LINUX_SHARED_TYPES.contains(t) {
|
||||
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
|
||||
continue;
|
||||
}
|
||||
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
|
||||
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
|
||||
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
|
||||
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
|
||||
} else {
|
||||
assert!(globs.contains(e), "{t} has no *.{e} glob");
|
||||
}
|
||||
}
|
||||
for t in &listed {
|
||||
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
|
||||
}
|
||||
for (t, globs, _) in &declared {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
|
||||
for g in globs {
|
||||
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="10" height="10" fill="currentColor" fill-opacity="0.4"/><rect x="10" y="10" width="10" height="10"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="7" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 7h10"/><circle cx="6" cy="12" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 12h8"/><circle cx="6" cy="17" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 17h11"/></svg>
|
||||
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 460 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -127,5 +127,16 @@ sync_github_name = true
|
||||
[people.dennismutuku2005]
|
||||
sync_github_name = true
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.makesomethingshit]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MiQ71]
|
||||
real_name = "Mirko Kerezovic"
|
||||
sync_github_name = false
|
||||
|
||||
[people.KaboomFox]
|
||||
sync_github_name = true
|
||||
@@ -287,6 +287,8 @@ pub struct ArtBrush {
|
||||
pub width: f64,
|
||||
pub flip_along: bool,
|
||||
pub flip_across: bool,
|
||||
/// When true, art is placed on each run between sharp corners so folds do not overlap.
|
||||
pub overlap: bool,
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
@@ -304,6 +306,7 @@ impl Default for ArtBrush {
|
||||
width: 100.0,
|
||||
flip_along: false,
|
||||
flip_across: false,
|
||||
overlap: false,
|
||||
colorization: Colorization::Tints,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,28 @@ fn art_brush_between_guides_keeps_ends() {
|
||||
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_brush_overlap_splits_at_corners() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((100.0, 0.0));
|
||||
bp.line_to((100.0, 100.0));
|
||||
let brush = |overlap: bool| Brush {
|
||||
name: "a".into(),
|
||||
kind: BrushKind::Art(ArtBrush {
|
||||
art: rect_art(10.0, 4.0),
|
||||
scale: ArtScale::Stretch,
|
||||
overlap,
|
||||
colorization: Colorization::None,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush(false), &bp, &stroke(1.0));
|
||||
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush(true), &bp, &stroke(1.0));
|
||||
assert_eq!(one.len(), 1, "one bent copy along the whole path");
|
||||
assert_eq!(two.len(), 2, "one copy per run between corners");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_brush_follows_curves() {
|
||||
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
|
||||
|
||||
+51
-21
@@ -154,35 +154,65 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
}
|
||||
|
||||
/// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp
|
||||
/// corner (the whole path when it has none).
|
||||
fn art_runs(t: &Track) -> Vec<(f64, f64)> {
|
||||
let l = t.len();
|
||||
let corners = t.corner_positions();
|
||||
if corners.is_empty() {
|
||||
return vec![(0.0, l)];
|
||||
}
|
||||
if t.closed {
|
||||
let first = corners[0].1;
|
||||
let mut bounds = corners.iter().map(|c| c.1).collect::<Vec<_>>();
|
||||
bounds.push(first + l);
|
||||
return bounds.windows(2).map(|w| (w[0], w[1])).collect();
|
||||
}
|
||||
let mut bounds = vec![0.0];
|
||||
bounds.extend(corners.iter().map(|c| c.1));
|
||||
bounds.push(l);
|
||||
bounds.windows(2).map(|w| (w[0], w[1])).collect()
|
||||
}
|
||||
|
||||
fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option<Node> {
|
||||
let l = s1 - s0;
|
||||
if l <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let w = b.width();
|
||||
let (along, cross) = match a.scale {
|
||||
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
|
||||
ArtScale::Proportional => {
|
||||
let k = l / w;
|
||||
(AlongMap::linear(w, s0, s1), k * a.width / 100.0)
|
||||
}
|
||||
ArtScale::BetweenGuides { start, end } => {
|
||||
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
|
||||
let (g0, g1) = (g0.min(g1), g0.max(g1));
|
||||
let fixed = (g0 + (w - g1)) * cross;
|
||||
if fixed >= l || g1 - g0 < 1e-9 {
|
||||
(AlongMap::linear(w, s0, s1), cross)
|
||||
} else {
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross)
|
||||
}
|
||||
}
|
||||
};
|
||||
bend(t, art, b, along, cross)
|
||||
}
|
||||
|
||||
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
|
||||
let cross = weight * a.width / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
if t.len() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let w = b.width();
|
||||
let (along, cross) = match a.scale {
|
||||
ArtScale::Stretch => (AlongMap::linear(w, 0.0, l), cross),
|
||||
ArtScale::Proportional => {
|
||||
let k = l / w;
|
||||
(AlongMap::linear(w, 0.0, l), k * a.width / 100.0)
|
||||
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] };
|
||||
for (s0, s1) in runs {
|
||||
if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) {
|
||||
out.push(n);
|
||||
}
|
||||
ArtScale::BetweenGuides { start, end } => {
|
||||
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
|
||||
let (g0, g1) = (g0.min(g1), g0.max(g1));
|
||||
let fixed = (g0 + (w - g1)) * cross;
|
||||
if fixed >= l || g1 - g0 < 1e-9 {
|
||||
(AlongMap::linear(w, 0.0, l), cross)
|
||||
} else {
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [0.0, g0 * cross, l - (w - g1) * cross, l] }, cross)
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(n) = bend(&t, &art, b, along, cross) {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -56,6 +56,84 @@ struct Sub {
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
|
||||
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
|
||||
/// relative tolerance rather than exact control-point tests.
|
||||
struct CircularSub {
|
||||
centre: Point,
|
||||
radius: f64,
|
||||
samples: Vec<Point>,
|
||||
}
|
||||
|
||||
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
|
||||
let u = 1.0 - t;
|
||||
let a = u * u * u;
|
||||
let b = 3.0 * u * u * t;
|
||||
let c = 3.0 * u * t * t;
|
||||
let d = t * t * t;
|
||||
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
|
||||
}
|
||||
|
||||
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
|
||||
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
|
||||
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
|
||||
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
|
||||
return None;
|
||||
}
|
||||
let mut samples = vec![sub.start];
|
||||
let mut from = sub.start;
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
for i in 1..=8 {
|
||||
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
|
||||
}
|
||||
from = to;
|
||||
}
|
||||
if samples.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let mut anchors = vec![sub.start];
|
||||
anchors.extend(sub.segs.iter().map(|s| s.end()));
|
||||
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
anchors.pop();
|
||||
}
|
||||
let (p0, p1, p2) = if anchors.len() >= 3 {
|
||||
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
|
||||
} else {
|
||||
(samples[0], samples[samples.len() / 2], *samples.last()?)
|
||||
};
|
||||
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
|
||||
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
|
||||
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let q0 = p0.x * p0.x + p0.y * p0.y;
|
||||
let q1 = p1.x * p1.x + p1.y * p1.y;
|
||||
let q2 = p2.x * p2.x + p2.y * p2.y;
|
||||
let centre = Point::new(
|
||||
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
|
||||
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
|
||||
);
|
||||
let radius = centre.distance(p0);
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (radius * 5e-4).max(1e-6);
|
||||
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
|
||||
return None;
|
||||
}
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
fn angle_deg(p: Point, centre: Point) -> f64 {
|
||||
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
|
||||
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
|
||||
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
|
||||
}
|
||||
|
||||
/// The subpaths of `bp` (quadratics raised to cubics).
|
||||
fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
let mut out = vec![];
|
||||
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
|
||||
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
|
||||
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
|
||||
// its width. Non-circular curves continue through the existing fallback.
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
|
||||
self.e.entity("SEQEND", end, h, &layer, style, "");
|
||||
}
|
||||
|
||||
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
|
||||
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
|
||||
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
|
||||
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
|
||||
let geometrically_closed = last.distance(sub.start) <= 1e-9;
|
||||
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
|
||||
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
|
||||
// before fitting, updating extents or allocating an entity handle.
|
||||
if sub.closed != geometrically_closed {
|
||||
return false;
|
||||
}
|
||||
let Some(circle) = circular_subpath(sub) else { return false };
|
||||
let centre = self.to_dxf * circle.centre;
|
||||
let radius = circle.radius * self.k.abs();
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count sampled points in the drawing extents. For a full circle include all cardinal
|
||||
// points exactly; for an arc, the dense samples conservatively cover its visible span.
|
||||
if sub.closed {
|
||||
let r = circle.radius;
|
||||
for p in [
|
||||
Point::new(circle.centre.x - r, circle.centre.y),
|
||||
Point::new(circle.centre.x + r, circle.centre.y),
|
||||
Point::new(circle.centre.x, circle.centre.y - r),
|
||||
Point::new(circle.centre.x, circle.centre.y + r),
|
||||
] {
|
||||
self.pt(p);
|
||||
}
|
||||
} else {
|
||||
for p in &circle.samples {
|
||||
self.pt(*p);
|
||||
}
|
||||
}
|
||||
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
if sub.closed {
|
||||
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
return true;
|
||||
}
|
||||
|
||||
let start = self.to_dxf * sub.start;
|
||||
let end = self.to_dxf * last;
|
||||
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
|
||||
let mut a0 = angle_deg(start, centre);
|
||||
let mut a1 = angle_deg(end, centre);
|
||||
let am = angle_deg(middle, centre);
|
||||
if !on_ccw_arc(a0, a1, am) {
|
||||
std::mem::swap(&mut a0, &mut a1);
|
||||
}
|
||||
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDbArc");
|
||||
}
|
||||
self.e.num(50, a0);
|
||||
self.e.num(51, a1);
|
||||
true
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
|
||||
+73
-14
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(
|
||||
kinds(&out),
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
|
||||
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
|
||||
"the fill's outline and hatch, then the stroke, then the circle's stroke"
|
||||
);
|
||||
let e = entities(&out);
|
||||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||||
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
|
||||
let hatch = &e[1];
|
||||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||||
// The ellipse: four cubics through clamped knots.
|
||||
let spline = &e[3];
|
||||
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
|
||||
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
|
||||
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
|
||||
// The circular ellipse is preserved as a native circle (#1299).
|
||||
let circle = &e[3];
|
||||
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
|
||||
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
// R13 has splines and hatches, and polylines of vertices.
|
||||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||||
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
|
||||
// R12: polylines only; the fill is its outline, the ellipse flattened.
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
|
||||
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let k = kinds(&r12);
|
||||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||||
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
|
||||
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_and_circular_arcs_are_native_dxf_entities() {
|
||||
let d = doc_with(|d| {
|
||||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||||
let c = Point::new(120.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
vec![
|
||||
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
]
|
||||
});
|
||||
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
|
||||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
|
||||
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
|
||||
}
|
||||
|
||||
// An actual ellipse is not close enough to a circle and remains a spline.
|
||||
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r12_stroked_circles_keep_polyline_width() {
|
||||
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kind(&e[0]), "POLYLINE");
|
||||
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
|
||||
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||||
let d = doc_with(|d| {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||||
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||||
arc.close_path();
|
||||
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
|
||||
let modern = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
// Each fill and stroke: its outlines, then its hatch.
|
||||
let fill = ["LWPOLYLINE", "HATCH"];
|
||||
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
}
|
||||
|
||||
@@ -451,7 +510,7 @@ fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
// Only the ellipse (120..180) reaches into 115..200.
|
||||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
|
||||
}
|
||||
|
||||
|
||||
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
/// Grid points per Lab channel of the gamut table.
|
||||
const GAMUT_GRID: usize = 9;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
|
||||
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
|
||||
/// TIFF that embedded one, #2707).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
|
||||
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
|
||||
for i in 0..GAMUT_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
|
||||
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
|
||||
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
|
||||
// PCS side.
|
||||
a_curves: identity(inputs + outputs - 3),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
};
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let gamut = table(3, 1, GAMUT_GRID, gamut);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
p.gamut = Some(gamut);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
|
||||
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
|
||||
/// then can't show a CMYK TIFF that embeds it.
|
||||
#[test]
|
||||
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let icc = 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;
|
||||
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
|
||||
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
|
||||
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
|
||||
}
|
||||
let p = parse(&icc);
|
||||
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -739,9 +739,10 @@ pub struct Appearance {
|
||||
pub items: Vec<AppearanceItem>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Groups, layers and type: how many items paint below the object's contents (its members, or
|
||||
/// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`:
|
||||
/// every item paints above the contents (see [`Self::contents_at`]).
|
||||
/// Groups, layers, type and images: how many items paint below the object's contents (its
|
||||
/// members, type's characters, or an image's pixels), the slot of the Appearance panel's
|
||||
/// Contents (Characters) row. `None`: every item paints above the contents (see
|
||||
/// [`Self::contents_at`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contents_index: Option<usize>,
|
||||
}
|
||||
|
||||
+47
-9
@@ -1,11 +1,12 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HitKind {
|
||||
@@ -62,14 +63,37 @@ pub struct HitOptions {
|
||||
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
|
||||
/// bounds.
|
||||
pub type_path_only: bool,
|
||||
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
|
||||
/// on or the guides are hidden.
|
||||
pub skip_guides: bool,
|
||||
}
|
||||
|
||||
impl Default for HitOptions {
|
||||
fn default() -> Self {
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
|
||||
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
|
||||
/// above this crate, so the engine registers one with [`set_image_paint`].
|
||||
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
|
||||
|
||||
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
|
||||
|
||||
/// Register how the shapes of an image's own fills and strokes are found (the first registration
|
||||
/// stays).
|
||||
pub fn set_image_paint(f: ImagePaint) {
|
||||
// Already set: the same engine, registered by another session.
|
||||
let _ = IMAGE_PAINT.set(f);
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
|
||||
/// nothing is registered.
|
||||
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
|
||||
}
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||||
@@ -318,6 +342,9 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
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_of(c, opt.scope)
|
||||
@@ -433,12 +460,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
|
||||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||||
}
|
||||
NodeKind::Text(_)
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
// An image is hit within its frame, and where its own fills and strokes paint
|
||||
// ([`image_paint`]) except in outline mode.
|
||||
NodeKind::Image(_) => {
|
||||
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return Some(HitKind::Bounds);
|
||||
}
|
||||
if opt.outline {
|
||||
return None;
|
||||
}
|
||||
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
|
||||
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
|
||||
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
|
||||
})
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
|
||||
+85
-4
@@ -36,6 +36,7 @@ pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod objects_on_path;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
@@ -215,13 +216,33 @@ impl Unit {
|
||||
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
|
||||
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
|
||||
pub fn ruler_step(self, zoom: f64) -> f64 {
|
||||
const STEPS: [f64; 19] =
|
||||
const DECIMAL: [f64; 19] =
|
||||
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
|
||||
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
|
||||
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
|
||||
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
|
||||
// between them grow denser, down to a hundred and twenty-eighth
|
||||
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
|
||||
// the left ruler has room for when it spells one down the ruler a character at a time.
|
||||
// From an inch up the ladder is the shared one.
|
||||
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
|
||||
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
|
||||
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
}
|
||||
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
|
||||
/// units, points, pixels and picas are read in tenths.
|
||||
fn ruler_halves(self) -> bool {
|
||||
matches!(self, Unit::Inches)
|
||||
}
|
||||
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
|
||||
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
|
||||
/// as dense as the tenths elsewhere; a tenth of it otherwise.
|
||||
pub fn ruler_minor(self, step: f64) -> f64 {
|
||||
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
|
||||
}
|
||||
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
|
||||
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
|
||||
let tick = self.ruler_step(zoom) / 10.0 * self.points();
|
||||
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
|
||||
(v / tick).round() * tick
|
||||
}
|
||||
pub fn from_pt(self, v: f64) -> f64 {
|
||||
@@ -274,7 +295,12 @@ impl Unit {
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
|
||||
// is left to its label and key.
|
||||
Unit::ALL
|
||||
.into_iter()
|
||||
.find(|u| letters(u.label()).eq(given()))
|
||||
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
@@ -801,6 +827,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
@@ -1504,6 +1536,7 @@ impl Document {
|
||||
n += usize::from(*k);
|
||||
}
|
||||
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
|
||||
sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten());
|
||||
let before = self.guides.len();
|
||||
let mut flags = kept.into_iter();
|
||||
self.guides.retain(|_| flags.next().unwrap_or(true));
|
||||
@@ -1549,9 +1582,17 @@ impl Document {
|
||||
{
|
||||
dimension.source = None;
|
||||
}
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.rename(&renamed);
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -1792,6 +1833,46 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
|
||||
/// are binary fractions now, and the minor ticks between them stay binary too; every other
|
||||
/// unit is still read in tenths.
|
||||
#[test]
|
||||
fn an_inch_ruler_steps_in_binary_fractions() {
|
||||
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
|
||||
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
|
||||
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
|
||||
let s = step(Unit::Inches, zoom);
|
||||
if s <= 1.0 {
|
||||
let k = s.recip();
|
||||
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
|
||||
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
|
||||
}
|
||||
// A label step always leaves at least 50 px, which is what the ladder is picked for.
|
||||
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
|
||||
}
|
||||
// The steps the report named: tenths became quarters, eighths and sixteenths.
|
||||
assert_eq!(step(Unit::Inches, 5.0), 0.25);
|
||||
assert_eq!(step(Unit::Inches, 10.0), 0.125);
|
||||
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
|
||||
// An inch and wider is unchanged.
|
||||
assert_eq!(step(Unit::Inches, 1.0), 1.0);
|
||||
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
|
||||
// further only adds ticks. A label therefore never needs more than the six characters of
|
||||
// "0.0625", which the left ruler has room for when it spells one out a character per line.
|
||||
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
|
||||
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
|
||||
}
|
||||
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
|
||||
// 128ths the report expects to appear unlabelled when zoomed in close.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
|
||||
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
|
||||
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
|
||||
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
|
||||
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
|
||||
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
|
||||
}
|
||||
|
||||
fn doc_with_rects() -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
|
||||
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
b += e.objects_on_path.as_deref().map_or(0, |o| {
|
||||
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
|
||||
});
|
||||
}
|
||||
b += match &n.kind {
|
||||
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
|
||||
|
||||
@@ -82,6 +82,31 @@ impl DocMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
|
||||
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
|
||||
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
|
||||
/// bytes twice (#2880).
|
||||
///
|
||||
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
|
||||
/// encryption: neither may be pinned by the environment.
|
||||
pub fn output_time() -> Option<i64> {
|
||||
pinned_or(|n| std::env::var(n).ok(), now_unix)
|
||||
}
|
||||
|
||||
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
|
||||
/// variable is preferred and that the clock answers whenever it does not.
|
||||
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
|
||||
stamp_from_env(get).or_else(clock)
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
|
||||
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
|
||||
/// the clock decides — an export is never refused over the environment.
|
||||
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
|
||||
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
@@ -148,4 +173,38 @@ mod tests {
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
|
||||
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
|
||||
#[test]
|
||||
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
|
||||
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
|
||||
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
|
||||
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
|
||||
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
|
||||
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp_from_env(env(None)), None, "unset");
|
||||
// Another variable never stands in for it.
|
||||
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
|
||||
}
|
||||
|
||||
/// What a file records: the variable when it is usable, the clock whenever it is not. The
|
||||
/// environment and the clock are passed in, so no test changes this process's environment --
|
||||
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
|
||||
#[test]
|
||||
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
|
||||
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
|
||||
let clock = || Some(1_000_000);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
|
||||
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
|
||||
for ignored in [None, Some(""), Some("-1"), Some("later")] {
|
||||
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
|
||||
}
|
||||
// A platform with no clock and no variable has no date to record.
|
||||
assert_eq!(pinned_or(env(None), || None), None);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
|
||||
}
|
||||
}
|
||||
+71
-9
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
@@ -102,6 +102,8 @@ pub enum LiveShape {
|
||||
},
|
||||
Polygon {
|
||||
radius: f64,
|
||||
/// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read.
|
||||
#[serde(deserialize_with = "de_sides")]
|
||||
sides: u32,
|
||||
xf: Affine,
|
||||
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
|
||||
@@ -326,6 +328,12 @@ fn all_round(kinds: &[CornerKind]) -> bool {
|
||||
kinds.iter().all(|k| *k == CornerKind::Round)
|
||||
}
|
||||
|
||||
/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its
|
||||
/// corner lists hold one entry per side, so a huge count would allocate gigabytes.
|
||||
fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
|
||||
u32::deserialize(d).map(|n| n.clamp(3, 1000))
|
||||
}
|
||||
|
||||
/// Whether `a` only moves (no rotation, scale, shear or reflection).
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
@@ -572,6 +580,9 @@ pub struct NodeExtras {
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
/// [`crate::objects_on_path`]).
|
||||
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -590,6 +601,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -724,6 +736,7 @@ impl Serialize for Node {
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -762,6 +775,7 @@ impl Serialize for Node {
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -821,6 +835,8 @@ struct NodeWire {
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -840,6 +856,7 @@ impl From<NodeWire> for Node {
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -1025,6 +1042,7 @@ impl Node {
|
||||
match &self.kind {
|
||||
NodeKind::Layer { .. } => "Layer",
|
||||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||||
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
|
||||
NodeKind::Group { .. } => "Group",
|
||||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||||
NodeKind::Path { guide: true, .. } => "Guide",
|
||||
@@ -1087,8 +1105,10 @@ impl Node {
|
||||
}
|
||||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
|
||||
/// measured as in [`Node::visual_bounds`].
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
|
||||
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
|
||||
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
@@ -1106,6 +1126,22 @@ impl Node {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// The members' reach, grown by the container's own strokes.
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
let o = self.appearance.outset();
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols)))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
|
||||
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
|
||||
// the farthest it can reach from its shape.
|
||||
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
|
||||
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
|
||||
}),
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
@@ -1155,9 +1191,9 @@ impl Node {
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
|
||||
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
|
||||
/// ([`Node::visual_bounds_with`] measures its art).
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is
|
||||
/// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`],
|
||||
/// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
self.visual(None)
|
||||
}
|
||||
@@ -1232,6 +1268,8 @@ impl Node {
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// An image is measured by its frame, without its own fills and strokes.
|
||||
NodeKind::Image(_) => self.geometric(symbols),
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1251,6 +1289,12 @@ impl Node {
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
// The frames of the objects attached to a path move with them.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.transform(a);
|
||||
}
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
@@ -1683,11 +1727,11 @@ impl Node {
|
||||
|
||||
impl Node {
|
||||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||||
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
|
||||
/// whose fills and strokes paint their own geometry.
|
||||
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
|
||||
/// for type; `None` for objects whose fills and strokes paint their own geometry.
|
||||
pub fn contents_label(&self) -> Option<&'static str> {
|
||||
match self.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"),
|
||||
NodeKind::Text(_) => Some("Characters"),
|
||||
_ => None,
|
||||
}
|
||||
@@ -2154,6 +2198,24 @@ mod tests {
|
||||
assert_eq!(path.label(), "Path");
|
||||
}
|
||||
|
||||
/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one
|
||||
/// with four billion made its outline and corner lists allocate tens of gigabytes.
|
||||
#[test]
|
||||
fn a_polygon_from_a_file_has_3_to_1000_sides() {
|
||||
let read = |sides: u32| {
|
||||
let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]});
|
||||
serde_json::from_value::<LiveShape>(json).unwrap()
|
||||
};
|
||||
let sides_of = |l: &LiveShape| match l {
|
||||
LiveShape::Polygon { sides, .. } => *sides,
|
||||
_ => panic!("not a polygon: {l:?}"),
|
||||
};
|
||||
let huge = read(4_000_000_000);
|
||||
assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]);
|
||||
assert_eq!(sides_of(&huge), 1000);
|
||||
assert_eq!(huge.polygon_outline().anchor_count(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Object › Objects on Path: objects attached to a path and spread along it.
|
||||
//!
|
||||
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
|
||||
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
|
||||
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
|
||||
//! saves and exports as a group. The record lists the objects in their order along the path, each
|
||||
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
|
||||
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
|
||||
//! one sits on the end of an open path), puts the attachment point of each one
|
||||
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
|
||||
//! tilted by [`ObjectsOnPath::rotate`].
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::blend::Spine;
|
||||
use crate::symbols::SymbolBoxes;
|
||||
use crate::{Node, NodeId, NodeKind};
|
||||
|
||||
/// The center of the nine reference points.
|
||||
pub const CENTER: u8 = 4;
|
||||
|
||||
fn center() -> u8 {
|
||||
CENTER
|
||||
}
|
||||
|
||||
/// What makes a group an Objects on Path group (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ObjectsOnPath {
|
||||
/// The child of the group that is the path.
|
||||
pub path: NodeId,
|
||||
/// The attached objects, in their order along the path.
|
||||
#[serde(default)]
|
||||
pub members: Vec<Member>,
|
||||
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
|
||||
/// from the top left of the object's bounds in its frame (4: the center).
|
||||
#[serde(default = "center")]
|
||||
pub pivot: u8,
|
||||
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
|
||||
/// upright object has its frame's x axis along the path's direction at its attachment point.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub rotate: f64,
|
||||
}
|
||||
|
||||
/// An object attached to the path of an Objects on Path group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "MemberWire")]
|
||||
pub struct Member {
|
||||
pub id: NodeId,
|
||||
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
|
||||
pub frame: Affine,
|
||||
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
|
||||
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
|
||||
/// the frame's x axis before then.
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
|
||||
#[derive(Deserialize)]
|
||||
struct MemberWire {
|
||||
id: NodeId,
|
||||
frame: Affine,
|
||||
#[serde(default)]
|
||||
angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<MemberWire> for Member {
|
||||
fn from(w: MemberWire) -> Self {
|
||||
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
|
||||
Self { id: w.id, frame: w.frame, angle }
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `frame`'s x axis (radians, the document's axes).
|
||||
fn x_angle(frame: Affine) -> f64 {
|
||||
let [a0, a1, ..] = frame.as_coeffs();
|
||||
a1.atan2(a0)
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// `n` in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(n: &Node) -> Self {
|
||||
let frame = upright_frame(n);
|
||||
Self { id: n.id, frame, angle: x_angle(frame) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectsOnPath {
|
||||
/// A record for the objects `members` (in their order along the path) attached to `path`, each
|
||||
/// in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
|
||||
let members = members.iter().map(|n| Member::new(n)).collect();
|
||||
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
|
||||
}
|
||||
/// The frames go with the group, and each frame's angle becomes the transformed path's
|
||||
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
|
||||
/// and reflection when it spreads the objects along the transformed path again. A reflection
|
||||
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
let before = tilt(self.rotate);
|
||||
if a.determinant() < 0.0 && self.rotate != 0.0 {
|
||||
self.rotate = -self.rotate;
|
||||
}
|
||||
let after = tilt(self.rotate);
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
for m in &mut self.members {
|
||||
m.frame = a * m.frame;
|
||||
let (sin, cos) = (m.angle - before).sin_cos();
|
||||
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
|
||||
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
|
||||
m.angle = y.atan2(x) + after;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
|
||||
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
|
||||
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
|
||||
self.path = to(self.path);
|
||||
for m in &mut self.members {
|
||||
m.id = to(m.id);
|
||||
}
|
||||
}
|
||||
/// The group's children changed from `before` to `after`: a new child at the index of a member
|
||||
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
|
||||
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
|
||||
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
|
||||
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
|
||||
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
|
||||
let mut ids = HashMap::new();
|
||||
for (old, new) in before.iter().zip(after) {
|
||||
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
|
||||
ids.insert(old.id, new.id);
|
||||
}
|
||||
}
|
||||
if !ids.is_empty() {
|
||||
self.rename(&ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` an Objects on Path group?
|
||||
pub fn is_objects_on_path(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
|
||||
}
|
||||
|
||||
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
|
||||
/// (not a guide) whose first subpath has some length.
|
||||
pub fn attachable_path(n: &Node) -> Option<&PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
|
||||
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
|
||||
/// that frame, an object stands square to the path or across it.
|
||||
pub fn upright_frame(n: &Node) -> Affine {
|
||||
let a = n.bbox_angle;
|
||||
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
|
||||
match n.geometric_bounds() {
|
||||
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
|
||||
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
|
||||
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
|
||||
/// around. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
|
||||
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
|
||||
}
|
||||
|
||||
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
|
||||
/// counter-clockwise on the page.
|
||||
fn tilt(rotate: f64) -> f64 {
|
||||
-rotate.to_radians()
|
||||
}
|
||||
|
||||
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
|
||||
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
|
||||
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
|
||||
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
|
||||
let det = m.frame.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut local = n.clone();
|
||||
local.transform(m.frame.inverse(), false);
|
||||
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
|
||||
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
|
||||
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
|
||||
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
|
||||
}
|
||||
|
||||
/// Is `a` the identity, give or take rounding?
|
||||
fn negligible(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
|
||||
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
|
||||
/// record lets go of the objects that are no longer in the group, and children it does not list
|
||||
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
|
||||
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
|
||||
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
|
||||
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
|
||||
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
|
||||
let old = rec.clone();
|
||||
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
|
||||
let path_id = rec.path;
|
||||
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
|
||||
g.extras_mut().objects_on_path = None;
|
||||
return true;
|
||||
};
|
||||
let path = attachable_path(path).cloned();
|
||||
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
|
||||
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
|
||||
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
|
||||
for c in children.iter() {
|
||||
if c.id != path_id && listed.insert(c.id) {
|
||||
rec.members.push(Member::new(c));
|
||||
}
|
||||
}
|
||||
let mut moved = false;
|
||||
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
|
||||
let turn = tilt(rec.rotate);
|
||||
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
|
||||
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
|
||||
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
|
||||
let delta = next * m.frame.inverse();
|
||||
if !negligible(delta) {
|
||||
Arc::make_mut(c).transform(delta, false);
|
||||
*m = Member { id: m.id, frame: next, angle: dir + turn };
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec != old {
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
return true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn rect(id: u64, r: Rect) -> Node {
|
||||
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_spread_from_the_first_anchor() {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let s = stops(&open, 4).unwrap();
|
||||
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
|
||||
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let s = stops(&closed, 4).unwrap();
|
||||
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
|
||||
pts.sort();
|
||||
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
|
||||
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_frames_round_the_rotation_to_a_right_angle() {
|
||||
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 90.0;
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 30.0;
|
||||
let f = upright_frame(&n);
|
||||
let [a0, a1, ..] = f.as_coeffs();
|
||||
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_is_placed_by_its_art() {
|
||||
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
|
||||
// around it that instances are measured by without the symbols.
|
||||
let mut d = crate::Document::new(400.0, 400.0);
|
||||
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
|
||||
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
|
||||
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
|
||||
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let members = [&instance, &other];
|
||||
let mut g = Node::new(
|
||||
NodeId(4),
|
||||
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
|
||||
);
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
|
||||
let symbols = d.symbol_boxes();
|
||||
assert!(place(&mut g, &symbols));
|
||||
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
|
||||
let b = placed.geometric_bounds_with(&symbols).unwrap();
|
||||
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
|
||||
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
|
||||
let mut v = serde_json::to_value(m).unwrap();
|
||||
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
|
||||
v.as_object_mut().unwrap().remove("angle");
|
||||
let back: Member = serde_json::from_value(v).unwrap();
|
||||
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_without_its_path_is_a_plain_group() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
|
||||
let d = crate::Document::new(100.0, 100.0);
|
||||
assert!(place(&mut g, &d.symbol_boxes()));
|
||||
assert!(!is_objects_on_path(&g));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ pub struct Selection {
|
||||
/// guides and objects can be selected together, in either order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<usize>,
|
||||
/// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index
|
||||
/// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set
|
||||
/// with `key`; [`Self::prune`] clears it when no object is selected to align to it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_guide: Option<usize>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
@@ -67,6 +72,11 @@ impl Selection {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.key_guide = None;
|
||||
}
|
||||
/// Is there a key for Align: an object or a guide?
|
||||
pub fn has_key(&self) -> bool {
|
||||
self.key.is_some() || self.key_guide.is_some()
|
||||
}
|
||||
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
||||
self.clear();
|
||||
@@ -114,6 +124,9 @@ impl Selection {
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
if self.key_guide == Some(i) {
|
||||
self.key_guide = None;
|
||||
}
|
||||
} else {
|
||||
self.guides.push(i);
|
||||
}
|
||||
@@ -162,13 +175,16 @@ impl Selection {
|
||||
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
|
||||
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
|
||||
});
|
||||
// The key object stays while another selected object lies outside it.
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
// The key object stays while another selected object lies outside it, or while ruler
|
||||
// guides are selected for it to line up (#2143).
|
||||
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
|
||||
let mut inside = BTreeSet::new();
|
||||
n.walk(&mut |c| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
self.objects.iter().any(|o| !inside.contains(o))
|
||||
self.objects.iter().any(|o| !inside.contains(o)) || (self.objects.contains(&n.id) && !self.guides.is_empty())
|
||||
});
|
||||
if !keep {
|
||||
self.key = None;
|
||||
@@ -177,8 +193,10 @@ impl Selection {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
// A key guide needs objects to line up on it, and stays selected.
|
||||
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
|
||||
self.key_guide = None;
|
||||
}
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
|
||||
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
|
||||
assert_eq!(Unit::named(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_suffix_names_the_plain_unit() {
|
||||
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
|
||||
@@ -107,9 +107,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
|
||||
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
|
||||
// paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays
|
||||
// type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
|
||||
+117
-8
@@ -56,7 +56,7 @@ mod tests;
|
||||
mod tests_scale;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{
|
||||
@@ -73,7 +73,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
@@ -153,7 +153,9 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.graphicPen" => always(&["strokeLength"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
"sketch.notePaper" => always(&["relief"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
@@ -362,6 +364,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
|
||||
),
|
||||
r(
|
||||
"artistic.plasticWrap",
|
||||
"Plastic Wrap…",
|
||||
ARTISTIC,
|
||||
"{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.posterEdges",
|
||||
"Poster Edges…",
|
||||
@@ -369,6 +378,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.smudgeStick",
|
||||
"Smudge Stick…",
|
||||
ARTISTIC,
|
||||
"{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.sponge",
|
||||
"Sponge…",
|
||||
ARTISTIC,
|
||||
"{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.watercolor",
|
||||
"Watercolor…",
|
||||
ARTISTIC,
|
||||
"{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}),
|
||||
),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
@@ -467,13 +497,83 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.basRelief",
|
||||
"Bas Relief…",
|
||||
SKETCH,
|
||||
"{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}),
|
||||
),
|
||||
r(
|
||||
"sketch.chalkCharcoal",
|
||||
"Chalk & Charcoal…",
|
||||
SKETCH,
|
||||
"{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.",
|
||||
json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.charcoal",
|
||||
"Charcoal…",
|
||||
SKETCH,
|
||||
"{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.chrome",
|
||||
"Chrome…",
|
||||
SKETCH,
|
||||
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 4.0, "smoothness": 4.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.graphicPen",
|
||||
"Graphic Pen…",
|
||||
SKETCH,
|
||||
"{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.",
|
||||
json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
SKETCH,
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
|
||||
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
|
||||
),
|
||||
r(
|
||||
"sketch.notePaper",
|
||||
"Note Paper…",
|
||||
SKETCH,
|
||||
"{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
|
||||
json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.photocopy",
|
||||
"Photocopy…",
|
||||
SKETCH,
|
||||
"{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 7.0, "darkness": 25.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.plaster",
|
||||
"Plaster…",
|
||||
SKETCH,
|
||||
"{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}),
|
||||
),
|
||||
r(
|
||||
"sketch.reticulation",
|
||||
"Reticulation…",
|
||||
SKETCH,
|
||||
"{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.stamp",
|
||||
"Stamp…",
|
||||
SKETCH,
|
||||
"{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"lightDarkBalance": 25.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
@@ -841,6 +941,12 @@ pub struct GeomContext<'a> {
|
||||
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
|
||||
/// way and keeps its result on every redraw.
|
||||
pub seed: u64,
|
||||
/// The object's appearance, which live-effect plug-ins are given read only (their fills and
|
||||
/// strokes).
|
||||
pub appearance: Option<&'a Appearance>,
|
||||
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
|
||||
/// the object's effects.
|
||||
pub item: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> GeomContext<'a> {
|
||||
@@ -850,15 +956,17 @@ impl<'a> GeomContext<'a> {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
|
||||
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
|
||||
}
|
||||
|
||||
/// The context of the effects on `item`, one of the same object's appearance items: a
|
||||
/// stroke's own effects outline that stroke.
|
||||
/// stroke's own effects outline that stroke, and a plug-in is told which item it is on.
|
||||
pub fn item(self, item: &'a AppearanceItem) -> Self {
|
||||
let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item)));
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
|
||||
AppearanceItem::Fill(_) => self,
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self },
|
||||
AppearanceItem::Fill(_) => Self { item: at, ..self },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,7 +1005,8 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges). Original filters built from
|
||||
//! public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand};
|
||||
|
||||
const SALT_PLASTIC: u64 = 512;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -239,6 +241,79 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15).
|
||||
pub(super) fn plastic_reach(smoothness: f64) -> f64 {
|
||||
3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0))
|
||||
}
|
||||
|
||||
/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds
|
||||
/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights
|
||||
/// as strong as `highlight` (0..20) are screened over it. Coverage is kept.
|
||||
pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) {
|
||||
if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32;
|
||||
if strength == 0.0 {
|
||||
return;
|
||||
}
|
||||
let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32;
|
||||
let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px;
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = px.len();
|
||||
let mut height = vec![0.0f32; len];
|
||||
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64);
|
||||
let mut noise = CellNoise::default();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
let l = luma(straight(*p));
|
||||
let q = place.at(x, y);
|
||||
let nq = Point::new(q.x / cell_scale, q.y / cell_scale);
|
||||
height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32;
|
||||
});
|
||||
for (h, a) in height.iter_mut().zip(&alpha) {
|
||||
*h *= *a;
|
||||
}
|
||||
blur_plane(&mut height, w, h, sigma);
|
||||
let mut cov = alpha.clone();
|
||||
blur_plane(&mut cov, w, h, sigma);
|
||||
for (h, c) in height.iter_mut().zip(&cov) {
|
||||
*h = if *c > 1e-4 { *h / *c } else { 0.0 };
|
||||
}
|
||||
const LIGHT: [f32; 3] = [0.35, -0.25, 0.9];
|
||||
let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6);
|
||||
let light = LIGHT.map(|v| v / inv);
|
||||
let scale = (8.0 * space.px).max(1e-6) as f32;
|
||||
let sample = |x: isize, y: isize| -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
|
||||
return 0.0;
|
||||
}
|
||||
height[y as usize * w + x as usize]
|
||||
};
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize);
|
||||
let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1);
|
||||
let nx = -gx / scale;
|
||||
let ny = -gy / scale;
|
||||
let nz = 1.0;
|
||||
let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
|
||||
let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0);
|
||||
let spec = ndotl.powi(12) * strength;
|
||||
if spec <= 1e-5 {
|
||||
return;
|
||||
}
|
||||
let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
|
||||
pub(super) fn glow_spread(size: f64) -> f64 {
|
||||
0.25 * size.abs().min(24.0)
|
||||
|
||||
@@ -64,9 +64,9 @@ impl StrokeDirection {
|
||||
}
|
||||
|
||||
/// Along a right diagonal stroke, rising to the right (y down).
|
||||
const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
pub(super) const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
/// Along a left diagonal stroke, falling to the right.
|
||||
const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
|
||||
pub(super) const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
|
||||
|
||||
/// The most samples a stroke averages, whatever its length.
|
||||
const MAX_SAMPLES: usize = 16;
|
||||
@@ -75,7 +75,7 @@ const MAX_SAMPLES: usize = 16;
|
||||
const JITTER: f64 = 0.25;
|
||||
|
||||
/// How soft (points, the σ) the tones are that decide which strokes a pixel gets.
|
||||
const TONE_SOFTNESS: f64 = 1.0;
|
||||
pub(super) const TONE_SOFTNESS: f64 = 1.0;
|
||||
|
||||
/// Dark Strokes' short dark strokes and long light ones (points).
|
||||
pub(super) const DARK_LENGTH: f64 = 3.0;
|
||||
@@ -86,7 +86,7 @@ const INK_SOFTNESS: f64 = 0.6;
|
||||
|
||||
/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling
|
||||
/// between pixels add to a filter's reach.
|
||||
const MARGIN: f64 = 1.0;
|
||||
pub(super) const MARGIN: f64 = 1.0;
|
||||
|
||||
/// How far (document units) strokes `len` long reach: half their length, their random shift, the
|
||||
/// softening between their samples and the pixels a sample is taken between.
|
||||
@@ -137,18 +137,18 @@ pub(super) fn spray_shift(radius: f64) -> f64 {
|
||||
|
||||
/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object
|
||||
/// softened first when they lie more than a pixel apart.
|
||||
struct Smear<'a> {
|
||||
pub(super) struct Smear<'a> {
|
||||
/// Premultiplied colours (0..1).
|
||||
src: Vec<[f32; 4]>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
place: &'a Place,
|
||||
len: f64,
|
||||
pub(super) len: f64,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl<'a> Smear<'a> {
|
||||
fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
|
||||
pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
|
||||
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
|
||||
let span = len / place.px;
|
||||
let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES };
|
||||
@@ -218,7 +218,7 @@ impl<'a> Smear<'a> {
|
||||
}
|
||||
|
||||
/// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart.
|
||||
fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
|
||||
pub(super) fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
|
||||
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
|
||||
}
|
||||
}
|
||||
@@ -242,37 +242,37 @@ fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide.
|
||||
fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
|
||||
pub(super) fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
|
||||
let (u, v) = frame(q, dir);
|
||||
2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0
|
||||
}
|
||||
|
||||
/// The straight colour and coverage of premultiplied `pm` (0..1).
|
||||
fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
|
||||
pub(super) fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
|
||||
let a = pm[3].clamp(0.0, 1.0);
|
||||
if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) }
|
||||
}
|
||||
|
||||
/// `a` + (`b` − `a`) × `t`, channel by channel.
|
||||
fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
|
||||
pub(super) fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
|
||||
std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t)
|
||||
}
|
||||
|
||||
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1.
|
||||
fn ramp(t: f64) -> f64 {
|
||||
pub(super) fn ramp(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1.
|
||||
fn unit(p: [u8; 4]) -> [f32; 4] {
|
||||
pub(super) fn unit(p: [u8; 4]) -> [f32; 4] {
|
||||
p.map(|v| f32::from(v) / 255.0)
|
||||
}
|
||||
|
||||
/// The object's edges after softening it by `sigma` (document units): about 1 across a step from
|
||||
/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't
|
||||
/// match.
|
||||
fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
pub(super) fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
@@ -516,3 +516,40 @@ pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
*p = premultiply(out, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
const SALT_GRAPHIC: u64 = 200;
|
||||
|
||||
/// Graphic Pen: the object redrawn in fine linear ink strokes along `dir`, each `len` (1..15)
|
||||
/// points long on white paper; `balance` (0..100) sets the tone threshold (higher draws more
|
||||
/// black ink).
|
||||
pub(super) fn graphic_pen(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, balance: f64, dir: StrokeDirection) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = if len.is_finite() { len.clamp(1.0, 15.0) } else { 1.0 };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let threshold = 1.0 - balance.clamp(0.0, 100.0) / 100.0;
|
||||
let row = (0.2 * len).max(0.75);
|
||||
let along = dir.along();
|
||||
place.each(px, |x, y, p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (c, stroke_a) = unpremultiply(smear.stroke(SALT_GRAPHIC, q, along, row));
|
||||
let dark = (1.0 - tones.get(x, y)).max(f64::from(1.0 - luma(c)) * f64::from(stroke_a));
|
||||
if dark <= threshold {
|
||||
*p = premultiply([1.0, 1.0, 1.0], a);
|
||||
return;
|
||||
}
|
||||
let weight = ramp((dark - threshold) / (1.0 - threshold).max(1e-3));
|
||||
let u = frame(q, along).0;
|
||||
let on_stroke = {
|
||||
let t = (u / len).rem_euclid(1.0);
|
||||
if t < 0.65 { 1.0 } else { 0.0 }
|
||||
};
|
||||
let grain = 0.08 * streak(SALT_GRAPHIC + 1, q, along, 0.35 * len, 0.5);
|
||||
let ink = (weight * on_stroke + grain).clamp(0.0, 1.0);
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], a);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap and
|
||||
//! Poster Edges, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
|
||||
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
|
||||
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
|
||||
//! Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Plaster and
|
||||
//! Reticulation, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
@@ -19,9 +21,11 @@ mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod loaded;
|
||||
mod paint;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod sketch;
|
||||
mod sketch_ink;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
@@ -39,11 +43,15 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 46] = [
|
||||
"artistic.cutout",
|
||||
"artistic.filmGrain",
|
||||
"artistic.neonGlow",
|
||||
"artistic.plasticWrap",
|
||||
"artistic.posterEdges",
|
||||
"artistic.smudgeStick",
|
||||
"artistic.sponge",
|
||||
"artistic.watercolor",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -62,7 +70,17 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.basRelief",
|
||||
"sketch.chalkCharcoal",
|
||||
"sketch.charcoal",
|
||||
"sketch.chrome",
|
||||
"sketch.graphicPen",
|
||||
"sketch.halftonePattern",
|
||||
"sketch.notePaper",
|
||||
"sketch.photocopy",
|
||||
"sketch.plaster",
|
||||
"sketch.reticulation",
|
||||
"sketch.stamp",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -84,10 +102,23 @@ pub enum PixelFx {
|
||||
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
|
||||
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
|
||||
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
|
||||
/// Artistic › Smudge Stick: the dark areas smudged along short diagonal strokes `stroke`
|
||||
/// (0..10) long, the light areas (widened by `highlight`, 0..20) smudged longer and lightened
|
||||
/// by up to `intensity` (0..10).
|
||||
SmudgeStick { stroke: f64, highlight: f64, intensity: f64 },
|
||||
/// Artistic › Sponge: soft blobs of lighter and darker colour about `brush` (0..10) large,
|
||||
/// contrasting as `definition` (0..25) says, their edges as soft as `smoothness` (1..15).
|
||||
Sponge { brush: f64, definition: f64, smoothness: f64 },
|
||||
/// Artistic › Watercolor: detail simplified by a brush of `detail` (1..14), edges saturated,
|
||||
/// shadows deepened by `shadow` (0..10), `texture` (1..3) grains of paper showing.
|
||||
Watercolor { detail: f64, shadow: f64, texture: u32 },
|
||||
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
|
||||
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
|
||||
/// strong as `brightness` (0..50).
|
||||
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
|
||||
/// Artistic › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief
|
||||
/// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15).
|
||||
PlasticWrap { highlight: f64, detail: f64, smoothness: f64 },
|
||||
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
|
||||
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
|
||||
PosterEdges { thickness: f64, intensity: f64, posterization: f64 },
|
||||
@@ -139,9 +170,38 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones;
|
||||
/// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight.
|
||||
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
||||
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
|
||||
Chrome { detail: f64, smoothness: f64 },
|
||||
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
|
||||
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
|
||||
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
|
||||
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
|
||||
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
|
||||
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
|
||||
/// Sketch › Note Paper: embossed tones on fibrous paper (`balance`, `graininess`, `relief`).
|
||||
NotePaper { balance: f64, graininess: f64, relief: f64 },
|
||||
/// Sketch › Bas Relief: low relief lit from `light`, keeping the finer features as `detail`
|
||||
/// (1..15) says and rounded by `smoothness` (1..15).
|
||||
BasRelief { detail: f64, smoothness: f64, light: Light },
|
||||
/// Sketch › Charcoal: bold edges `thickness` (1..7) wide, as fine as `detail` (0..5) says, and
|
||||
/// diagonal strokes over the midtones; `balance` (0..100) sets how far into the lights it reaches.
|
||||
Charcoal { thickness: f64, detail: f64, balance: f64 },
|
||||
/// Sketch › Photocopy: black only where a pixel is darker than its surroundings, as fine as
|
||||
/// `detail` (1..24) says, and as readily as `darkness` (1..50) says.
|
||||
Photocopy { detail: f64, darkness: f64 },
|
||||
/// Sketch › Stamp: solid black and white, printing black as much as `balance` (0..50) says,
|
||||
/// its shapes rounded by `smoothness` (1..50).
|
||||
Stamp { balance: f64, smoothness: f64 },
|
||||
/// Sketch › Plaster: relief plaster lit from `light`; dark areas raised, light recessed;
|
||||
/// `imageBalance` (1..50) shifts more of the image lower at higher values; `smoothness` (1..15)
|
||||
/// softens the relief.
|
||||
Plaster { image_balance: f64, smoothness: f64, light: Light },
|
||||
/// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen;
|
||||
/// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain.
|
||||
Reticulation { density: f64, foreground: f64, background: f64 },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
@@ -242,6 +302,26 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
|
||||
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"artistic.smudgeStick" => PixelFx::SmudgeStick {
|
||||
stroke: num(p, "strokeLength", 2.0).clamp(0.0, 10.0),
|
||||
highlight: num(p, "highlightArea", 12.0).clamp(0.0, 20.0),
|
||||
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"artistic.sponge" => PixelFx::Sponge {
|
||||
brush: num(p, "brushSize", 2.0).clamp(0.0, 10.0),
|
||||
definition: num(p, "definition", 12.0).clamp(0.0, 25.0),
|
||||
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"artistic.watercolor" => PixelFx::Watercolor {
|
||||
detail: num(p, "brushDetail", 9.0).clamp(1.0, 14.0),
|
||||
shadow: num(p, "shadowIntensity", 1.0).clamp(0.0, 10.0),
|
||||
texture: num(p, "texture", 1.0).clamp(1.0, 3.0).round() as u32,
|
||||
},
|
||||
"artistic.plasticWrap" => PixelFx::PlasticWrap {
|
||||
highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0),
|
||||
detail: num(p, "detail", 12.0).clamp(0.0, 15.0),
|
||||
smoothness: num(p, "smoothness", 15.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"blur.radial" => PixelFx::RadialBlur {
|
||||
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
|
||||
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
|
||||
@@ -311,11 +391,53 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.chalkCharcoal" => PixelFx::ChalkCharcoal {
|
||||
chalk: num(p, "chalkArea", 6.0).clamp(0.0, 20.0),
|
||||
charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0),
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
||||
},
|
||||
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
|
||||
"sketch.graphicPen" => PixelFx::GraphicPen {
|
||||
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
|
||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
|
||||
},
|
||||
"sketch.notePaper" => PixelFx::NotePaper {
|
||||
balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0),
|
||||
graininess: num(p, "graininess", 0.0).clamp(0.0, 10.0),
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"sketch.basRelief" => PixelFx::BasRelief {
|
||||
detail: num(p, "detail", 13.0).clamp(1.0, 15.0),
|
||||
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
|
||||
light: Light::parse(text(p, "lightDirection", "bottom")),
|
||||
},
|
||||
"sketch.charcoal" => PixelFx::Charcoal {
|
||||
thickness: num(p, "thickness", 1.0).clamp(1.0, 7.0),
|
||||
detail: num(p, "detail", 1.0).clamp(0.0, 5.0),
|
||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||
},
|
||||
"sketch.photocopy" => {
|
||||
PixelFx::Photocopy { detail: num(p, "detail", 7.0).clamp(1.0, 24.0), darkness: num(p, "darkness", 25.0).clamp(1.0, 50.0) }
|
||||
}
|
||||
"sketch.stamp" => {
|
||||
PixelFx::Stamp { balance: num(p, "lightDarkBalance", 25.0).clamp(0.0, 50.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 50.0) }
|
||||
}
|
||||
"sketch.plaster" => PixelFx::Plaster {
|
||||
image_balance: num(p, "imageBalance", 20.0).clamp(1.0, 50.0),
|
||||
smoothness: num(p, "smoothness", 8.0).clamp(1.0, 15.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
},
|
||||
"sketch.reticulation" => PixelFx::Reticulation {
|
||||
density: num(p, "density", 12.0).clamp(0.0, 50.0),
|
||||
foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0),
|
||||
background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
@@ -381,7 +503,9 @@ impl PixelFx {
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::AccentedEdges { .. } => 0.0,
|
||||
// Strokes reach out of the object as far as they reach into it.
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length),
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } | PixelFx::InkOutlines { length, .. } => {
|
||||
brushstrokes::stroke_reach(length)
|
||||
}
|
||||
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
|
||||
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
|
||||
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
|
||||
@@ -392,11 +516,21 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. }
|
||||
| PixelFx::ChalkCharcoal { .. }
|
||||
| PixelFx::Chrome { .. }
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
| PixelFx::NotePaper { .. }
|
||||
| PixelFx::Plaster { .. }
|
||||
| PixelFx::Reticulation { .. } => 0.0,
|
||||
PixelFx::BasRelief { .. } | PixelFx::Charcoal { .. } | PixelFx::Photocopy { .. } | PixelFx::Stamp { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
// These three keep the object's coverage.
|
||||
PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0,
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Cutout { .. }
|
||||
| PixelFx::NeonGlow { .. }
|
||||
| PixelFx::PlasticWrap { .. }
|
||||
| PixelFx::PosterEdges { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
@@ -420,9 +554,14 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
|
||||
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
|
||||
PixelFx::SmudgeStick { stroke, .. } => Some(paint::smudge_reach(stroke)),
|
||||
PixelFx::Sponge { brush, smoothness, .. } => Some(paint::sponge_reach(brush, smoothness)),
|
||||
PixelFx::Watercolor { detail, .. } => Some(paint::watercolor_reach(detail)),
|
||||
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
|
||||
// The strokes, and the softened tones that choose them.
|
||||
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } => {
|
||||
Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
PixelFx::Crosshatch { length, .. } => {
|
||||
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
@@ -435,15 +574,27 @@ impl PixelFx {
|
||||
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
|
||||
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
PixelFx::BasRelief { detail, smoothness, .. } => Some(sketch_ink::relief_reach(detail, smoothness)),
|
||||
PixelFx::Charcoal { thickness, detail, .. } => Some(sketch_ink::charcoal_reach(thickness, detail)),
|
||||
PixelFx::Photocopy { detail, .. } => Some(sketch_ink::copy_reach(detail)),
|
||||
PixelFx::Stamp { smoothness, .. } => Some(sketch_ink::stamp_reach(smoothness)),
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
||||
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
| PixelFx::NotePaper { .. }
|
||||
| PixelFx::Reticulation { .. } => Some(0.0),
|
||||
PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)),
|
||||
// Cutout's palette comes from the whole object.
|
||||
PixelFx::Cutout { .. } => None,
|
||||
// The softened tones around the pixel.
|
||||
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
|
||||
PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)),
|
||||
// The edges' lines, spread round them.
|
||||
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
@@ -468,6 +619,10 @@ impl PixelFx {
|
||||
match *self {
|
||||
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
|
||||
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
|
||||
PixelFx::SmudgeStick { stroke, highlight, intensity } => paint::smudge_stick(px, w, h, space, stroke, highlight, intensity),
|
||||
PixelFx::Sponge { brush, definition, smoothness } => paint::sponge(px, w, h, space, brush, definition, smoothness),
|
||||
PixelFx::Watercolor { detail, shadow, texture } => paint::watercolor(px, w, h, space, detail, shadow, texture),
|
||||
PixelFx::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness),
|
||||
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
|
||||
PixelFx::PosterEdges { thickness, intensity, posterization } => {
|
||||
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
|
||||
@@ -493,7 +648,17 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
||||
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
|
||||
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
|
||||
PixelFx::BasRelief { detail, smoothness, light } => sketch_ink::bas_relief(px, w, h, space, detail, smoothness, light),
|
||||
PixelFx::Charcoal { thickness, detail, balance } => sketch_ink::charcoal(px, w, h, space, thickness, detail, balance),
|
||||
PixelFx::Photocopy { detail, darkness } => sketch_ink::photocopy(px, w, h, space, detail, darkness),
|
||||
PixelFx::Stamp { balance, smoothness } => sketch_ink::stamp(px, w, h, space, balance, smoothness),
|
||||
PixelFx::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light),
|
||||
PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
@@ -594,6 +759,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
|
||||
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
|
||||
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
|
||||
/// less, or when the sizes don't match.
|
||||
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let radius = radius.min(MAX_BOX as f64);
|
||||
let whole = radius.floor() as usize;
|
||||
let frac = (radius - whole as f64) as f32;
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
max_plane(a, &mut tmp, len, whole, frac);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
@@ -703,3 +907,7 @@ pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
#[cfg(test)]
|
||||
mod tests_sketch;
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Effect › Artistic › Smudge Stick, Sponge and Watercolor. Original filters built from public
|
||||
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html> (Smudge Stick smudges the
|
||||
//! dark areas with short diagonal strokes and brightens the light ones, Sponge paints highly
|
||||
//! textured areas of contrasting colour, Watercolor simplifies detail with a medium brush loaded
|
||||
//! with water and saturates the colour where tones change at edges). Visual approximations, not
|
||||
//! reproductions of a proprietary algorithm; the ranges and defaults are chosen for VectorCraft.
|
||||
//!
|
||||
//! All three keep the object's coverage, so they paint nothing outside it. Their patterns lie in
|
||||
//! document space around the object's centre, as the Brush Strokes' and Texture filters' do.
|
||||
|
||||
use super::blur;
|
||||
use super::brushstrokes::{MARGIN, RIGHT, Smear, TONE_SOFTNESS, edges, mix, ramp, streak, stroke_reach, unpremultiply};
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, Px16, gaussian, to16};
|
||||
|
||||
const SALT_SMUDGE: u64 = 320;
|
||||
const SALT_SPONGE: u64 = 328;
|
||||
const SALT_WATER: u64 = 336;
|
||||
|
||||
/// The length (points) of Smudge Stick's short strokes for Stroke Length `stroke` (0..10); the
|
||||
/// light areas are smudged with strokes twice as long.
|
||||
fn smudge_length(stroke: f64) -> f64 {
|
||||
1.0 + 1.5 * stroke.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Smudge Stick reaches for Stroke Length `stroke`: its strokes and the
|
||||
/// softened tones that choose them.
|
||||
pub(super) fn smudge_reach(stroke: f64) -> f64 {
|
||||
stroke_reach(2.0 * smudge_length(stroke)).max(3.0 * TONE_SOFTNESS + MARGIN)
|
||||
}
|
||||
|
||||
/// The σ (points) Sponge softens the object by, for Brush Size `brush` (0..10) and Smoothness
|
||||
/// `smoothness` (1..15).
|
||||
fn sponge_softness(brush: f64, smoothness: f64) -> f64 {
|
||||
(0.15 + 0.04 * smoothness.clamp(1.0, 15.0)) * sponge_pore(brush)
|
||||
}
|
||||
|
||||
/// The size (points) of Sponge's pores for Brush Size `brush` (0..10).
|
||||
fn sponge_pore(brush: f64) -> f64 {
|
||||
2.0 + 1.5 * brush.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Sponge reaches: the softened colours around a pixel.
|
||||
pub(super) fn sponge_reach(brush: f64, smoothness: f64) -> f64 {
|
||||
3.0 * sponge_softness(brush, smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// The radius (points) of the brush that simplifies detail for Brush Detail `detail` (1..14): the
|
||||
/// less detail, the wider the brush.
|
||||
fn water_brush(detail: f64) -> f64 {
|
||||
0.5 * (15.0 - detail.clamp(1.0, 14.0))
|
||||
}
|
||||
|
||||
/// The σ (points) of the softening that finds Watercolor's edges.
|
||||
const WATER_EDGE: f64 = 0.6;
|
||||
|
||||
/// How far (document units) Watercolor reaches for Brush Detail `detail`.
|
||||
pub(super) fn watercolor_reach(detail: f64) -> f64 {
|
||||
water_brush(detail) + 3.0 * WATER_EDGE + MARGIN
|
||||
}
|
||||
|
||||
/// Smudge Stick: the dark areas smudged along short diagonal strokes `stroke` (0..10) long, the
|
||||
/// light areas smudged with longer strokes and lightened towards white as strongly as `intensity`
|
||||
/// (0..10) says, so they lose their detail; `highlight` (0..20) widens the area counted as light.
|
||||
pub(super) fn smudge_stick(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke: f64, highlight: f64, intensity: f64) {
|
||||
if ![stroke, highlight, intensity].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = smudge_length(stroke);
|
||||
let (short, long) = (Smear::new(px, w, h, &place, len), Smear::new(px, w, h, &place, 2.0 * len));
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
// Tones above the threshold count as light; a wider highlight area lowers it.
|
||||
let threshold = 0.9 - 0.5 * (highlight / 20.0).clamp(0.0, 1.0);
|
||||
let lift = (0.7 * intensity / 10.0).clamp(0.0, 0.7) as f32;
|
||||
let row = (0.25 * len).max(1.0);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let own = straight(*p);
|
||||
let paint = |smear: &Smear, salt: u64| {
|
||||
let (c, a) = unpremultiply(smear.stroke(salt, q, RIGHT, row));
|
||||
if a > 0.0 { c } else { own }
|
||||
};
|
||||
// Bristle marks along the dark strokes.
|
||||
let bristle = (1.0 + 0.08 * streak(SALT_SMUDGE + 2, q, RIGHT, len, 0.5)) as f32;
|
||||
let dark = paint(&short, SALT_SMUDGE).map(|v| v * bristle);
|
||||
let light = paint(&long, SALT_SMUDGE + 1);
|
||||
let t = ramp((tones.get(x, y) - threshold) / 0.2 + 0.5) as f32;
|
||||
let bright = light.map(|v| v + (1.0 - v) * lift);
|
||||
*p = premultiply(mix(dark, bright, t), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sponge: the object as if dabbed with a sponge, soft blobs of lighter and darker colour about
|
||||
/// `brush` (0..10) large; `definition` (0..25) sets how strongly they contrast and `smoothness`
|
||||
/// (1..15) how soft their edges are.
|
||||
pub(super) fn sponge(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush: f64, definition: f64, smoothness: f64) {
|
||||
if ![brush, definition, smoothness].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let mut soft: Vec<Px16> = to16(px);
|
||||
gaussian(&mut soft, w, sponge_softness(brush, smoothness) / place.px);
|
||||
let pore = sponge_pore(brush);
|
||||
let edge = 0.12 + 0.03 * smoothness.clamp(1.0, 15.0);
|
||||
let amount = (definition / 25.0).clamp(0.0, 1.0);
|
||||
let gain = 1.0 + 1.2 * amount;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let alpha = f32::from(p[3]) / 255.0;
|
||||
let own = straight(*p);
|
||||
let (c, a) = unpremultiply(soft.get(y * w + x).map_or([0.0; 4], |s| s.map(|v| f32::from(v) / 65535.0)));
|
||||
let c = if a > 0.0 { c } else { own };
|
||||
let q = place.at(x, y);
|
||||
let n = 0.65 * smooth_noise(SALT_SPONGE, q, pore) + 0.35 * smooth_noise(SALT_SPONGE + 1, q, 0.45 * pore);
|
||||
// -1 inside a dark pore, 1 on a light bridge between pores.
|
||||
let blob = 2.0 * ramp(0.5 + n / (2.0 * edge)) - 1.0;
|
||||
let l = f64::from(luma(c));
|
||||
let target = (0.5 + (l + 0.55 * amount * blob - 0.5) * gain).clamp(0.0, 1.0);
|
||||
// Darker towards black, lighter towards white: the hue stays.
|
||||
let out = if target <= l {
|
||||
let k = if l > 1e-3 { (target / l) as f32 } else { 0.0 };
|
||||
c.map(|v| v * k)
|
||||
} else {
|
||||
let k = ((target - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v + (1.0 - v) * k)
|
||||
};
|
||||
*p = premultiply(out, alpha);
|
||||
});
|
||||
}
|
||||
|
||||
/// Watercolor: the object's detail simplified by a brush whose size `detail` (1..14) sets (the
|
||||
/// larger, the more detail stays), the colour saturated and its pigment pooled where tones change
|
||||
/// at edges, the shadows deepened as strongly as `shadow` (0..10) says, and `texture` (1..3)
|
||||
/// grains of paper showing through.
|
||||
pub(super) fn watercolor(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, shadow: f64, texture: u32) {
|
||||
if ![detail, shadow].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let coverage: Vec<u8> = px.iter().map(|p| p[3]).collect();
|
||||
// Simplify: average within areas of similar colour, keeping the bolder edges; a coarser brush
|
||||
// (less detail) also merges colours further apart.
|
||||
blur::smart(px, w, h, water_brush(detail) / place.px, 20.0 + 6.0 * (14.0 - detail.clamp(1.0, 14.0)), 7);
|
||||
let found = edges(px, w, h, &place, WATER_EDGE);
|
||||
let shadow = shadow.clamp(0.0, 10.0);
|
||||
let paper = 0.02 * f64::from(texture.clamp(1, 3));
|
||||
let grain = 1.0 + 0.5 * f64::from(texture.clamp(1, 3));
|
||||
place.each(px, |x, y, p| {
|
||||
let alpha = coverage.get(y * w + x).copied().unwrap_or(0);
|
||||
if alpha == 0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let c = straight(*p);
|
||||
let e = (2.0 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0))).clamp(0.0, 1.0) as f32;
|
||||
let l = luma(c);
|
||||
// Saturated colour and darker pigment where the tones change.
|
||||
let c = c.map(|v| (l + (v - l) * (1.0 + 0.9 * e)) * (1.0 - 0.12 * e));
|
||||
let deep = ((1.0 - l / 0.5).clamp(0.0, 1.0) * 0.07 * shadow as f32).min(0.8);
|
||||
let q = place.at(x, y);
|
||||
let n = 0.6 * smooth_noise(SALT_WATER, q, grain) + 0.4 * smooth_noise(SALT_WATER + 1, q, 0.5 * grain);
|
||||
let n = (paper * n) as f32;
|
||||
*p = premultiply(c.map(|v| v * (1.0 - deep) + n), f32::from(alpha) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,42 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Note Paper, Plaster and Reticulation; Graphic Pen lives in
|
||||
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
//! black and white) and the public Photoshop Elements Artistic filters guide. Visual
|
||||
//! approximations, not proprietary reproductions.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
||||
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand, value_noise};
|
||||
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_CHALK: u64 = 216;
|
||||
const SALT_CHROME: u64 = 0xC40_4D45;
|
||||
const EDGE_SOFT: f64 = 0.8;
|
||||
const TONE_SOFT: f64 = 1.0;
|
||||
/// Light from the top left (document axes, y down), as on polished relief surfaces.
|
||||
const CHROME_LIGHT: Vec2 = Vec2::new(-FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
|
||||
/// How far (document units) Chalk & Charcoal samples softened tones and edges.
|
||||
pub(super) const CHALK_CHARCOAL_REACH: f64 = 3.0 * TONE_SOFT + 1.0 + 3.0 * EDGE_SOFT;
|
||||
|
||||
fn ramp(t: f64) -> f64 {
|
||||
t.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn tone_edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
blur_plane(&mut tone, w, h, s);
|
||||
blur_plane(&mut cover, w, h, s);
|
||||
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
|
||||
let norm = if norm.is_finite() { norm } else { 0.0 };
|
||||
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
|
||||
}
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
@@ -69,3 +99,261 @@ pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &P
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Note Paper: the object embossed on fibrous paper. `balance` (0..50) shifts tones toward
|
||||
/// highlights or shadows around the middle; `graininess` (0..10) adds paper fibre; `relief`
|
||||
/// (0..50, points) is how strongly the tones stand out in the light. Coverage is kept.
|
||||
pub(super) fn note_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, graininess: f64, relief: f64) {
|
||||
if !(balance.is_finite() && graininess.is_finite() && relief.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let height: Vec<f32> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p[3] == 0 {
|
||||
0.0
|
||||
} else {
|
||||
let mut l = f64::from(luma(straight(*p)));
|
||||
l += (balance - 25.0) / 125.0;
|
||||
l.clamp(0.0, 1.0) as f32
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sample = |x: i32, y: i32| -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
|
||||
return 0.0;
|
||||
}
|
||||
height.get(y as usize * w + x as usize).copied().unwrap_or(0.0)
|
||||
};
|
||||
let emboss = relief / 50.0;
|
||||
let grain = graininess / 10.0;
|
||||
let light = (-0.6_f64, -0.6, 0.8);
|
||||
let ln = (light.0 * light.0 + light.1 * light.1 + light.2 * light.2).sqrt().max(1e-9);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let xi = x as i32;
|
||||
let yi = y as i32;
|
||||
let h0 = height.get(y * w + x).copied().unwrap_or(0.0);
|
||||
let dx = (sample(xi + 1, yi) - sample(xi - 1, yi)) as f64;
|
||||
let dy = (sample(xi, yi + 1) - sample(xi, yi - 1)) as f64;
|
||||
let dz = emboss * 0.12;
|
||||
let nx = -dx;
|
||||
let ny = -dy;
|
||||
let nz = dz;
|
||||
let nn = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
|
||||
let shade = ((nx * light.0 + ny * light.1 + nz * light.2) / (nn * ln) * 0.5 + 0.5).clamp(0.0, 1.0);
|
||||
let tone = (0.35 + 0.65 * f64::from(h0)) * shade;
|
||||
let q = place.at(x, y);
|
||||
let fibre = if grain > 0.0 { value_noise(0x4e4f_5450, q.x * 0.35, q.y * 0.35) * grain * 0.18 } else { 0.0 };
|
||||
let v = (tone + fibre).clamp(0.0, 1.0) as f32;
|
||||
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal: light areas get chalky highlights and dark areas charcoal strokes and grain;
|
||||
/// `chalk` and `charcoal` (0..20) set how strongly, `pressure` (0..5) the stroke weight. Black
|
||||
/// chalk and charcoal on white paper, coverage kept.
|
||||
pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, chalk: f64, charcoal: f64, pressure: f64) {
|
||||
if !(chalk.is_finite() && charcoal.is_finite() && pressure.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let scale = (pressure.clamp(0.0, 5.0) / 5.0).clamp(0.0, 1.0);
|
||||
let chalk_amt = (0.05 * chalk.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
let charcoal_amt = (0.05 * charcoal.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
if chalk_amt <= 0.0 && charcoal_amt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
|
||||
let edges = tone_edges(px, w, h, &place, EDGE_SOFT);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let tone = tones.get(x, y);
|
||||
let dark = (1.0 - tone).clamp(0.0, 1.0);
|
||||
let light = tone.clamp(0.0, 1.0);
|
||||
let edge = f64::from(edges.get(y * w + x).copied().unwrap_or(0.0));
|
||||
let charcoal_stroke = value_noise(SALT_CHALK + 1, q.x * 1.1, q.y * 0.12);
|
||||
let charcoal_grain = value_noise(SALT_CHALK, q.x * 0.35, q.y * 0.35);
|
||||
let charcoal_mask = ramp((dark - 0.3) / 0.22)
|
||||
* charcoal_amt
|
||||
* (0.35 + 0.65 * edge)
|
||||
* (0.45 + 0.55 * ramp(charcoal_stroke * 2.2 - 0.55))
|
||||
* (0.6 + 0.4 * charcoal_grain);
|
||||
let chalk_grain = value_noise(SALT_CHALK + 2, q.x * 0.45 + 7.0, q.y * 0.45);
|
||||
let chalk_mask = ramp((light - 0.5) / 0.22) * chalk_amt * (0.55 + 0.45 * chalk_grain);
|
||||
let down = (0.9 * charcoal_mask).clamp(0.0, 1.0) as f32;
|
||||
let up = (0.8 * chalk_mask).clamp(0.0, 1.0) as f32;
|
||||
let mut grey = luma(straight(*p));
|
||||
grey *= 1.0 - down;
|
||||
grey += (1.0 - grey) * up;
|
||||
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// How far (document units) Chrome reaches for `smoothness` (0..10): 3 σ of the height-field blur
|
||||
/// plus the Sobel neighbours.
|
||||
pub(super) fn chrome_reach(smoothness: f64) -> f64 {
|
||||
let smoothness = smoothness.clamp(0.0, 10.0);
|
||||
3.0 * (0.4 + 0.55 * smoothness) + 1.0
|
||||
}
|
||||
|
||||
/// Chrome: the object as a polished chrome surface lit from the top left. `detail` (0..10) adds
|
||||
/// finer relief; `smoothness` (0..10) softens the height field before the highlights and shadows
|
||||
/// are drawn. Coverage is kept.
|
||||
pub(super) fn chrome(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64) {
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let detail = detail.clamp(0.0, 10.0);
|
||||
let smoothness = smoothness.clamp(0.0, 10.0);
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = (0.4 + smoothness * 0.55) / place.px.max(1e-9);
|
||||
let cell = (14.0 - detail * 1.2).max(1.5);
|
||||
let bump = (detail / 10.0) * 0.4;
|
||||
let strength = 2.5 + detail * 0.35;
|
||||
let mut height: Vec<f32> = Vec::with_capacity(w * h);
|
||||
let mut alpha: Vec<f32> = Vec::with_capacity(w * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = px[y * w + x];
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
alpha.push(a);
|
||||
if a <= 0.0 {
|
||||
height.push(0.0);
|
||||
continue;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let base = f64::from(luma(straight(p)));
|
||||
let n = if bump > 0.0 { bump * smooth_noise(SALT_CHROME, q, cell) } else { 0.0 };
|
||||
height.push((base + n).clamp(0.0, 1.0) as f32);
|
||||
}
|
||||
}
|
||||
blur_plane(&mut height, w, h, sigma);
|
||||
blur_plane(&mut alpha, w, h, sigma);
|
||||
let tone: Vec<f64> = height.iter().zip(&alpha).map(|(h, a)| if *a > 1e-4 { f64::from(*h / *a).clamp(0.0, 1.0) } else { 0.0 }).collect();
|
||||
let px_unit = place.px.max(1e-9);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let at = |dx: isize, dy: isize| {
|
||||
let (xx, yy) = ((x as isize + dx).clamp(0, w as isize - 1) as usize, (y as isize + dy).clamp(0, h as isize - 1) as usize);
|
||||
tone[yy * w + xx]
|
||||
};
|
||||
let h0 = tone[y * w + x];
|
||||
let gx = 0.5 * (at(1, 0) - at(-1, 0)) / px_unit;
|
||||
let gy = 0.5 * (at(0, 1) - at(0, -1)) / px_unit;
|
||||
let face = -(gx * CHROME_LIGHT.x + gy * CHROME_LIGHT.y);
|
||||
let spec = (face * strength + (h0 - 0.5) * 1.5).tanh();
|
||||
let grey = (0.5 + 0.5 * spec).clamp(0.0, 1.0);
|
||||
*p = premultiply([grey as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
|
||||
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
|
||||
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
|
||||
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
|
||||
}
|
||||
|
||||
/// How a surface whose height rises along `slope` faces a light from `toward`.
|
||||
fn facing(slope: Vec2, toward: Vec2) -> f64 {
|
||||
-slope.dot(toward)
|
||||
}
|
||||
|
||||
/// The σ (document units) of Plaster's height blur at `smoothness` (1..15).
|
||||
fn plaster_sigma_pt(smoothness: f64) -> f64 {
|
||||
let smoothness = smoothness.clamp(1.0, 15.0);
|
||||
0.15 + 0.85 * (smoothness - 1.0) / 14.0
|
||||
}
|
||||
|
||||
/// How far (document units) Plaster's softened relief reaches at `smoothness` (1..15).
|
||||
pub(super) fn plaster_reach(smoothness: f64) -> f64 {
|
||||
if !smoothness.is_finite() {
|
||||
return 3.0 * plaster_sigma_pt(8.0) + 0.5;
|
||||
}
|
||||
3.0 * plaster_sigma_pt(smoothness) + 0.5
|
||||
}
|
||||
|
||||
/// Plaster: the object molded in relief plaster and lit from `light`. Dark areas are raised and
|
||||
/// light areas recessed; `imageBalance` (1..50) shifts more of the image lower at higher values;
|
||||
/// `smoothness` (1..15) softens the relief. Coverage is kept.
|
||||
pub(super) fn plaster(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, light: Light) {
|
||||
if !(image_balance.is_finite() && smoothness.is_finite() && space.px.is_finite() && space.px > 0.0)
|
||||
|| w == 0
|
||||
|| h == 0
|
||||
|| w.checked_mul(h) != Some(px.len())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let image_balance = image_balance.clamp(1.0, 50.0);
|
||||
let smoothness = smoothness.clamp(1.0, 15.0);
|
||||
let bias = (image_balance - 1.0) / 49.0;
|
||||
let mut height: Vec<f32> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let dark = (1.0 - luma(straight(*p))) * a;
|
||||
(dark - bias as f32 * 0.65).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
blur_plane(&mut height, w, h, plaster_sigma_pt(smoothness) / space.px);
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let step = (0.35f64).max(0.5 * place.px);
|
||||
let strength = 0.55;
|
||||
let toward = light.toward();
|
||||
let at = |x: usize, y: usize, dx: i32, dy: i32| {
|
||||
let (xx, yy) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
|
||||
height[yy * w + xx]
|
||||
};
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let h = height[y * w + x];
|
||||
let (left, right, up, down) = (at(x, y, -1, 0), at(x, y, 1, 0), at(x, y, 0, -1), at(x, y, 0, 1));
|
||||
let slope = Vec2::new(f64::from(right - left), f64::from(down - up)) / (2.0 * step);
|
||||
let ridge = f64::from(h - 0.25 * (left + right + up + down)) / step;
|
||||
let shade = strength * (facing(slope, toward) + ridge);
|
||||
*p = premultiply(lit(straight(*p), shade), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain
|
||||
/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each)
|
||||
/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is
|
||||
/// kept.
|
||||
pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) {
|
||||
if !(density.is_finite() && foreground.is_finite() && background.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// Finer grain at higher density: cells from about 11 pt down to 1 pt.
|
||||
let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2;
|
||||
let fg = foreground.clamp(0.0, 50.0) / 50.0;
|
||||
let bg = background.clamp(0.0, 50.0) / 50.0;
|
||||
let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0);
|
||||
const SALT: u64 = 0x5245_7469;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0);
|
||||
let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size);
|
||||
let fine = match (cell(q.x), cell(q.y)) {
|
||||
(Some(i), Some(j)) => rand(SALT + 1, i, j, 2),
|
||||
_ => 0.5,
|
||||
};
|
||||
let grain = 0.55 * coarse + 0.45 * fine;
|
||||
let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 };
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp. Original filters built from public
|
||||
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html> (the Sketch effects
|
||||
//! redraw the object in the foreground and background colours, black on white here; Bas Relief
|
||||
//! carves low relief lit from a direction, Charcoal draws bold edges and sketches the midtones in
|
||||
//! diagonal strokes, Photocopy copies only the edges of large dark areas and takes the midtones to
|
||||
//! black or white, Stamp simplifies the image to solid black and white as a rubber stamp would).
|
||||
//! Visual approximations, not reproductions of a proprietary algorithm; the ranges and defaults
|
||||
//! are chosen for VectorCraft.
|
||||
//!
|
||||
//! All four keep the object's coverage, so they paint nothing outside it. Their sizes are in
|
||||
//! document points, whatever the raster's resolution.
|
||||
|
||||
use std::f64::consts::FRAC_1_SQRT_2;
|
||||
|
||||
use super::pixelate::{Place, cell, value_noise};
|
||||
use super::texture::{Light, Tones, premultiply};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
|
||||
const SALT_STROKES: u64 = 440;
|
||||
|
||||
/// A pixel's size (points) at 72 ppi: how far the neighbours of a gradient add to a filter's reach.
|
||||
const MARGIN: f64 = 1.0;
|
||||
|
||||
/// Redraw `p` as ink `ink` (0..1) of black on white paper, keeping its coverage.
|
||||
fn paint(p: &mut [u8; 4], ink: f64) {
|
||||
let v = if ink.is_finite() { (1.0 - ink).clamp(0.0, 1.0) as f32 } else { 1.0 };
|
||||
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
|
||||
}
|
||||
|
||||
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1 (0 when `t` is not a number).
|
||||
fn ramp(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// The tone at (`x`, `y`) and its gradient per pixel, from central differences.
|
||||
fn gradient(tones: &Tones, x: usize, y: usize) -> (f64, f64, f64) {
|
||||
let gx = 0.5 * (tones.get(x + 1, y) - tones.get(x.saturating_sub(1), y));
|
||||
let gy = 0.5 * (tones.get(x, y + 1) - tones.get(x, y.saturating_sub(1)));
|
||||
(tones.get(x, y), gx, gy)
|
||||
}
|
||||
|
||||
/// The σ (points) Bas Relief softens the object by, for Detail `detail` (1..15) and Smoothness
|
||||
/// `smoothness` (1..15).
|
||||
fn relief_softness(detail: f64, smoothness: f64) -> f64 {
|
||||
0.2 + 0.15 * smoothness.clamp(1.0, 15.0) + 0.1 * (15.0 - detail.clamp(1.0, 15.0))
|
||||
}
|
||||
|
||||
/// How far (document units) Bas Relief reaches: the softened tones and their neighbours.
|
||||
pub(super) fn relief_reach(detail: f64, smoothness: f64) -> f64 {
|
||||
3.0 * relief_softness(detail, smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// Bas Relief: the object carved in low relief and lit from `light`. Surfaces facing the light turn
|
||||
/// white, those turned away black, flat areas grey with a hint of the object's own tones; `detail`
|
||||
/// (1..15) keeps finer features and `smoothness` (1..15) rounds the carving.
|
||||
pub(super) fn bas_relief(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64, light: Light) {
|
||||
if !(detail.is_finite() && smoothness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = relief_softness(detail, smoothness);
|
||||
let tones = Tones::new(px, w, h, sigma / place.px);
|
||||
let toward = light.toward();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let (tone, slope) = tones.slope(x, y, &place);
|
||||
// A step from black to white, softened by σ, rises 0.4 per σ at its steepest.
|
||||
let lit = -slope.dot(toward) * sigma;
|
||||
paint(p, 1.0 - (0.5 + 0.2 * (tone - 0.5) + 2.5 * lit));
|
||||
});
|
||||
}
|
||||
|
||||
/// The σ (points) Charcoal softens the object by to find its edges, for Detail `detail` (0..5).
|
||||
fn charcoal_softness(detail: f64) -> f64 {
|
||||
0.25 + 0.25 * (5.0 - detail.clamp(0.0, 5.0)) + 0.25
|
||||
}
|
||||
|
||||
/// The σ (points) that thickens Charcoal's edges, for Thickness `thickness` (1..7).
|
||||
fn charcoal_thickening(thickness: f64) -> f64 {
|
||||
0.35 * thickness.clamp(1.0, 7.0)
|
||||
}
|
||||
|
||||
/// The distance (points) between the strokes sketching Charcoal's midtones.
|
||||
fn charcoal_spacing(thickness: f64) -> f64 {
|
||||
1.5 + 0.5 * thickness.clamp(1.0, 7.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Charcoal reaches: the edges, their thickening and the strokes' tones.
|
||||
pub(super) fn charcoal_reach(thickness: f64, detail: f64) -> f64 {
|
||||
(3.0 * (charcoal_softness(detail) + charcoal_thickening(thickness)) + 2.0 * MARGIN).max(2.0 * charcoal_spacing(thickness) + MARGIN)
|
||||
}
|
||||
|
||||
/// Charcoal: bold, `thickness` (1..7) wide edges, as fine as `detail` (0..5) says, and the
|
||||
/// midtones sketched in diagonal strokes; `balance` (0..100) sets how far into the lights the
|
||||
/// charcoal reaches (50 is even).
|
||||
pub(super) fn charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, detail: f64, balance: f64) {
|
||||
if !(thickness.is_finite() && detail.is_finite() && balance.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = charcoal_softness(detail);
|
||||
let spacing = charcoal_spacing(thickness);
|
||||
let bias = (balance.clamp(0.0, 100.0) - 50.0) / 100.0 * 0.8;
|
||||
// The edges: how much the softened tones change across σ, 1 for a step from black to white.
|
||||
let edge_tones = Tones::new(px, w, h, sigma / place.px);
|
||||
let mut edge: Vec<f32> = (0..w * h)
|
||||
.map(|k| {
|
||||
let (_, gx, gy) = gradient(&edge_tones, k % w, k / w);
|
||||
let contrast = gx.hypot(gy) / place.px * sigma / 0.4;
|
||||
let weakest = 0.35 - 0.2 * bias / 0.4;
|
||||
ramp((contrast - weakest) / 0.3) as f32
|
||||
})
|
||||
.collect();
|
||||
blur_plane(&mut edge, w, h, charcoal_thickening(thickness) / place.px);
|
||||
// The strokes' tones are softer than the edges', so the strokes don't follow every speck.
|
||||
let tones = Tones::new(px, w, h, 0.5 * spacing / place.px);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let bold = edge.get(y * w + x).map_or(0.0, |e| ((f64::from(*e) - 0.3) / 0.3).clamp(0.0, 1.0));
|
||||
let dark = 1.0 - tones.get(x, y);
|
||||
let share = ((dark + bias - 0.2) / 0.6).clamp(0.0, 1.0);
|
||||
let q = place.at(x, y);
|
||||
// Across the strokes, which run rising to the right, and along them.
|
||||
let (across, along) = ((q.x + q.y) * FRAC_1_SQRT_2 / spacing, (q.x - q.y) * FRAC_1_SQRT_2 / spacing);
|
||||
let stroke = match (share > 0.0, cell(across)) {
|
||||
(true, Some(line)) => {
|
||||
let f = across - line as f64;
|
||||
// Each stroke breaks and swells along its length.
|
||||
let swell = (0.6 + 0.8 * value_noise(SALT_STROKES, along / 6.0, line as f64)).clamp(0.0, 1.2);
|
||||
let reach = (share * swell).min(1.0);
|
||||
let soft = 2.0 * place.px / spacing;
|
||||
(((reach - (2.0 * f - 1.0).abs()) / soft + 0.5).clamp(0.0, 1.0)) * f64::from(reach > 0.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
paint(p, bold.max(stroke));
|
||||
});
|
||||
}
|
||||
|
||||
/// The σ (points) of Photocopy's narrow softening for Detail `detail` (1..24): the less detail,
|
||||
/// the wider.
|
||||
fn copy_softness(detail: f64) -> f64 {
|
||||
0.15 * (25.0 - detail.clamp(1.0, 24.0))
|
||||
}
|
||||
|
||||
/// How much wider Photocopy's second softening is than its first.
|
||||
const COPY_WIDER: f64 = 3.0;
|
||||
|
||||
/// How far (document units) Photocopy reaches: the wide softening.
|
||||
pub(super) fn copy_reach(detail: f64) -> f64 {
|
||||
3.0 * COPY_WIDER * copy_softness(detail) + MARGIN
|
||||
}
|
||||
|
||||
/// Photocopy: black where a pixel is darker than its surroundings, white elsewhere. Large dark
|
||||
/// areas copy only around their edges and midtones go to black or white. `detail` (1..24) keeps
|
||||
/// finer features and `darkness` (1..50) copies more of the dark.
|
||||
pub(super) fn photocopy(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, darkness: f64) {
|
||||
if !(detail.is_finite() && darkness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let sigma = copy_softness(detail) / place.px;
|
||||
let narrow = Tones::new(px, w, h, sigma);
|
||||
let wide = Tones::new(px, w, h, COPY_WIDER * sigma);
|
||||
// How much darker than its surroundings a pixel must be to print.
|
||||
let threshold = 0.02 + 0.10 * (1.0 - (darkness.clamp(1.0, 50.0) - 1.0) / 49.0);
|
||||
let soft = 0.02;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
paint(p, ((wide.get(x, y) - narrow.get(x, y) - threshold) / soft + 0.5).clamp(0.0, 1.0));
|
||||
});
|
||||
}
|
||||
|
||||
/// The σ (points) Stamp softens the object by for Smoothness `smoothness` (1..50).
|
||||
fn stamp_softness(smoothness: f64) -> f64 {
|
||||
0.1 * smoothness.clamp(1.0, 50.0)
|
||||
}
|
||||
|
||||
/// How far (document units) Stamp reaches: the softened tones and their neighbours.
|
||||
pub(super) fn stamp_reach(smoothness: f64) -> f64 {
|
||||
3.0 * stamp_softness(smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// Stamp: the object as solid black and white, like a rubber stamp. `balance` (0..50) sets how much
|
||||
/// of the object prints black and `smoothness` (1..50) rounds the shapes.
|
||||
pub(super) fn stamp(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, smoothness: f64) {
|
||||
if !(balance.is_finite() && smoothness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let tones = Tones::new(px, w, h, stamp_softness(smoothness) / place.px);
|
||||
let threshold = 0.2 + 0.6 * balance.clamp(0.0, 50.0) / 50.0;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let (tone, gx, gy) = gradient(&tones, x, y);
|
||||
// A one pixel wide edge, whatever the softening.
|
||||
paint(p, ((threshold - tone) / gx.hypot(gy).max(0.01) + 0.5).clamp(0.0, 1.0));
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
|
||||
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
|
||||
// that growth as the raster scales would show as the glow's width jumping with the zoom.
|
||||
let mut dot = vec![0.0f32; 7 * 7];
|
||||
dot[3 * 7 + 3] = 255.0;
|
||||
let mut grown = dot.clone();
|
||||
dilate_plane(&mut grown, 7, 7, 1.0);
|
||||
assert_eq!(grown[2 * 7 + 2], 255.0);
|
||||
assert_eq!(grown[7 + 3], 0.0);
|
||||
let mut half = dot.clone();
|
||||
dilate_plane(&mut half, 7, 7, 1.5);
|
||||
assert_eq!(half[3 * 7 + 4], 255.0);
|
||||
assert_eq!(half[3 * 7 + 5], 127.5);
|
||||
assert_eq!(half[3 * 7 + 6], 0.0);
|
||||
let mut same = dot.clone();
|
||||
dilate_plane(&mut same, 7, 7, 0.0);
|
||||
assert_eq!(same, dot);
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
@@ -1978,6 +1998,47 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn chalk_charcoal(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.chalkCharcoal", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.chalkCharcoal", json!({})), PixelFx::ChalkCharcoal { chalk: 6.0, charcoal: 6.0, pressure: 2.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.chalkCharcoal", json!({"chalkArea": 1e9, "charcoalArea": -2, "strokePressure": 99})),
|
||||
PixelFx::ChalkCharcoal { chalk: 20.0, charcoal: 0.0, pressure: 5.0 }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.chalkCharcoal").unwrap().params;
|
||||
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
|
||||
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert!(f.reach().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
let out = chalk_charcoal(json!({"chalkArea": 12, "charcoalArea": 12, "strokePressure": 4}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "coverage kept");
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(p[0], p[1]);
|
||||
assert_eq!(p[1], p[2]);
|
||||
}
|
||||
assert_ne!(out, src);
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
@@ -1990,6 +2051,38 @@ fn period(values: &[u8]) -> usize {
|
||||
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_pen_params_take_defaults_and_stay_in_range() {
|
||||
use super::STROKE_DIRECTIONS;
|
||||
assert_eq!(fx("sketch.graphicPen", json!({})), PixelFx::GraphicPen { length: 5.0, balance: 50.0, direction: StrokeDirection::RightDiagonal });
|
||||
assert_eq!(
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 0, "lightDarkBalance": 200, "strokeDirection": "VERTICAL"})),
|
||||
PixelFx::GraphicPen { length: 1.0, balance: 100.0, direction: StrokeDirection::Vertical }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.graphicPen").unwrap().params;
|
||||
assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.graphicPen", json!({"strokeLength": 15}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0));
|
||||
assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
|
||||
assert_eq!(
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 99})),
|
||||
PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal }
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphic Pen on a mid-grey field draws both black ink and white paper.
|
||||
#[test]
|
||||
fn graphic_pen_draws_ink_and_paper_on_grey() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |_, _| grey(32));
|
||||
let mut out = src.clone();
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 4, "lightDarkBalance": 50})).apply(&mut out, w, h, &space(w, h));
|
||||
let lumas: Vec<u8> = out.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
|
||||
let (min, max) = lumas.iter().fold((255u8, 0u8), |(lo, hi), l| (lo.min(*l), hi.max(*l)));
|
||||
assert!(min < 80 && max > 200, "min {min} max {max}");
|
||||
assert_ne!(out, src, "the filter should change the raster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
|
||||
@@ -2013,6 +2106,44 @@ fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_paper_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.notePaper", json!({})), PixelFx::NotePaper { balance: 25.0, graininess: 0.0, relief: 4.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.notePaper", json!({"imageBalance": -1, "graininess": 20, "relief": 100})),
|
||||
PixelFx::NotePaper { balance: 0.0, graininess: 10.0, relief: 50.0 }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.notePaper").unwrap().params;
|
||||
assert!(doc.contains("imageBalance") && doc.contains("graininess") && doc.contains("relief"), "{doc}");
|
||||
let f = fx("sketch.notePaper", json!({"relief": 12}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Note Paper embosses a horizontal gradient and graininess adds fibre.
|
||||
#[test]
|
||||
fn note_paper_embosses_a_gradient_and_grain_changes_pixels() {
|
||||
let (w, h) = (32, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
|
||||
let flat = {
|
||||
let mut out = src.clone();
|
||||
fx("sketch.notePaper", json!({"relief": 0, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
let embossed = {
|
||||
let mut out = src.clone();
|
||||
fx("sketch.notePaper", json!({"relief": 50, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
assert_ne!(flat, embossed, "relief changes the emboss");
|
||||
let grainy = {
|
||||
let mut out = src.clone();
|
||||
fx("sketch.notePaper", json!({"relief": 0, "graininess": 10})).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
};
|
||||
assert_ne!(flat, grainy, "graininess adds fibre");
|
||||
}
|
||||
|
||||
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
|
||||
#[test]
|
||||
fn halftone_line_period_is_the_size_at_any_resolution() {
|
||||
@@ -2057,6 +2188,41 @@ fn halftone_pattern_type_changes_the_pattern() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plaster of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn plaster(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.plaster", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaster_params_take_defaults_and_stay_in_range() {
|
||||
use super::texture::Light;
|
||||
assert_eq!(fx("sketch.plaster", json!({})), PixelFx::Plaster { image_balance: 20.0, smoothness: 8.0, light: Light::Top });
|
||||
assert_eq!(
|
||||
fx("sketch.plaster", json!({"imageBalance": 0, "smoothness": 1e9, "lightDirection": "bottomRight"})),
|
||||
PixelFx::Plaster { image_balance: 1.0, smoothness: 15.0, light: Light::BottomRight }
|
||||
);
|
||||
assert_eq!(fx("sketch.plaster", json!({"lightDirection": "nonsense"})), fx("sketch.plaster", json!({})));
|
||||
let doc = crate::effect_info("sketch.plaster").unwrap().params;
|
||||
assert!(doc.contains("\"bottomRight\""), "{doc}");
|
||||
let f = fx("sketch.plaster", json!({"smoothness": 24}));
|
||||
assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0)));
|
||||
}
|
||||
|
||||
/// Plaster changes the image and different light directions give different results.
|
||||
#[test]
|
||||
fn plaster_light_direction_shifts_shading() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = image(w, h, |x, y| grey((16 + (x + y) % 200) as u8));
|
||||
let s = space(w, h);
|
||||
let base = plaster(json!({"imageBalance": 20, "smoothness": 6, "lightDirection": "top"}), &src, w, h, &s);
|
||||
assert_ne!(base, src, "plaster changes opaque pixels");
|
||||
let left = plaster(json!({"lightDirection": "left"}), &src, w, h, &s);
|
||||
let right = plaster(json!({"lightDirection": "right"}), &src, w, h, &s);
|
||||
assert_ne!(left, right, "light direction changes the relief shading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_contrast_sharpens_the_screen() {
|
||||
let (w, h) = (64, 32);
|
||||
@@ -2094,6 +2260,89 @@ fn halftone_draws_in_black_and_white_only() {
|
||||
}
|
||||
}
|
||||
|
||||
fn chrome(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.chrome", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.reticulation", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.chrome", json!({})), PixelFx::Chrome { detail: 4.0, smoothness: 4.0 });
|
||||
assert_eq!(fx("sketch.chrome", json!({"detail": -1, "smoothness": 99})), PixelFx::Chrome { detail: 0.0, smoothness: 10.0 });
|
||||
assert_eq!(fx("sketch.chrome", json!({"detail": "x"})), fx("sketch.chrome", json!({})));
|
||||
let doc = crate::effect_info("sketch.chrome").unwrap().params;
|
||||
assert!(doc.contains("detail") && doc.contains("smoothness"), "{doc}");
|
||||
let f = fx("sketch.chrome", json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(sketch::chrome_reach(4.0)));
|
||||
assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(), Some(sketch::chrome_reach(10.0)));
|
||||
}
|
||||
|
||||
/// Chrome redraws in greyscale, keeps coverage, and more detail changes the pattern.
|
||||
#[test]
|
||||
fn chrome_draws_polished_greyscale_relief() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |x, y| {
|
||||
let t = ((x + y) * 4) as u8;
|
||||
[t, t / 2, 255 - t, 255]
|
||||
});
|
||||
let soft = chrome(json!({"detail": 0, "smoothness": 8}), &src, w, h, &space(w, h));
|
||||
let sharp = chrome(json!({"detail": 10, "smoothness": 0}), &src, w, h, &space(w, h));
|
||||
assert_ne!(soft, sharp);
|
||||
for out in [&soft, &sharp] {
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2], "greyscale {p:?}");
|
||||
}
|
||||
assert!(share(out, |p| p[0] > 10 && p[0] < 245) > 0.05, "midtones");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})),
|
||||
PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 }
|
||||
);
|
||||
let f = fx("sketch.reticulation", json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
let out = reticulation(json!({}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_density_changes_the_grain() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(128));
|
||||
let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h));
|
||||
let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h));
|
||||
assert_ne!(low, high);
|
||||
}
|
||||
|
||||
/// The distinct colours of the covered pixels.
|
||||
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
|
||||
@@ -2128,7 +2377,12 @@ fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
|
||||
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
|
||||
assert_eq!(fx("artistic.plasticWrap", json!({})), PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
|
||||
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] {
|
||||
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
|
||||
}
|
||||
}
|
||||
@@ -2146,6 +2400,7 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
let filters = [
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
|
||||
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
|
||||
PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
|
||||
];
|
||||
for f in &filters {
|
||||
@@ -2176,6 +2431,26 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = image(w, h, |x, y| grey((((x + y) * 5) % 200 + 30) as u8));
|
||||
let mut none = src.clone();
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 0})).apply(&mut none, w, h, &space(w, h));
|
||||
assert_eq!(none, src);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.plasticWrap", json!({})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out != src, "defaults brighten some pixels");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
let mut weak = src.clone();
|
||||
let mut strong = src.clone();
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 4})).apply(&mut weak, w, h, &space(w, h));
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 20})).apply(&mut strong, w, h, &space(w, h));
|
||||
let lift =
|
||||
|a: &[u8], b: &[u8]| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).map(|(o, s)| i32::from(o[0]) - i32::from(s[0])).sum::<i32>();
|
||||
assert!(lift(&strong, &src) > lift(&weak, &src), "stronger highlights lift more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
|
||||
let (w, h) = (64, 24);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Artistic › Smudge Stick, Sponge and Watercolor.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(w / 2.0, h / 2.0),
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * w.hypot(h),
|
||||
}
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => p.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx(id, p).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
/// The mean of channel 0 over the columns `x0..x1`.
|
||||
fn mean(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
|
||||
let rows = d.len() / 4 / w;
|
||||
let sum: f64 = (0..rows).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f64::from(d[(y * w + x) * 4])).sum();
|
||||
sum / (rows * (x1 - x0)) as f64
|
||||
}
|
||||
|
||||
/// The summed difference between horizontal neighbours of channel 0 over columns `x0..x1`.
|
||||
fn roughness(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
|
||||
let rows = d.len() / 4 / w;
|
||||
(0..rows)
|
||||
.flat_map(|y| (x0..x1 - 1).map(move |x| (x, y)))
|
||||
.map(|(x, y)| (f64::from(d[(y * w + x) * 4]) - f64::from(d[(y * w + x + 1) * 4])).abs())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn spread(d: &[u8]) -> f64 {
|
||||
let v: Vec<f64> = d.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).collect();
|
||||
let m = v.iter().sum::<f64>() / v.len() as f64;
|
||||
(v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
|
||||
fn survives(id: &str, params: &[serde_json::Value]) {
|
||||
let (w, h) = (9, 7);
|
||||
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
|
||||
for p in params {
|
||||
for s in [tiny, huge, nan, space(w, h)] {
|
||||
let mut out = src.clone();
|
||||
fx(id, p.clone()).apply(&mut out, w, h, &s);
|
||||
assert_eq!(out.len(), src.len());
|
||||
}
|
||||
}
|
||||
// A size that doesn't match, and an empty raster, do nothing.
|
||||
let mut odd = vec![1u8; 10];
|
||||
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
|
||||
assert_eq!(odd, vec![1u8; 10]);
|
||||
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smudge_stick_smudges_the_dark_and_lightens_the_light() {
|
||||
assert_eq!(fx("artistic.smudgeStick", json!({})), PixelFx::SmudgeStick { stroke: 2.0, highlight: 12.0, intensity: 10.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.smudgeStick", json!({"strokeLength": 1e308, "highlightArea": -5, "intensity": "x"})),
|
||||
PixelFx::SmudgeStick { stroke: 10.0, highlight: 0.0, intensity: 10.0 }
|
||||
);
|
||||
// A fine checkerboard of dark tones on the left, of light tones on the right, and a half
|
||||
// transparent bottom.
|
||||
let (w, h) = (64, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let on = (x + y) % 2 == 0;
|
||||
let v = match (x < 32, on) {
|
||||
(true, true) => 20,
|
||||
(true, false) => 70,
|
||||
(false, true) => 190,
|
||||
(false, false) => 240,
|
||||
};
|
||||
if y < 40 { grey(v) } else { [v / 2, v / 2, v / 2, 128] }
|
||||
});
|
||||
let out = run("artistic.smudgeStick", json!({}), &src, w, h);
|
||||
assert_eq!(out, run("artistic.smudgeStick", json!({}), &src, w, h), "draws the same twice");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
|
||||
// The dark detail is smudged away, the light areas brighter.
|
||||
assert!(roughness(&out, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28));
|
||||
assert!(mean(&out, w, 36, 60) > mean(&src, w, 36, 60) + 10.0);
|
||||
// Intensity 0 leaves the light areas unlightened.
|
||||
let flat = run("artistic.smudgeStick", json!({"intensity": 0}), &src, w, h);
|
||||
assert!(mean(&flat, w, 36, 60) < mean(&out, w, 36, 60) - 5.0);
|
||||
survives(
|
||||
"artistic.smudgeStick",
|
||||
&[json!({}), json!({"strokeLength": 1e308, "highlightArea": 1e308, "intensity": 1e308}), json!({"strokeLength": "NaN"})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sponge_dabs_contrasting_blobs_and_keeps_the_hue() {
|
||||
assert_eq!(fx("artistic.sponge", json!({})), PixelFx::Sponge { brush: 2.0, definition: 12.0, smoothness: 5.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.sponge", json!({"brushSize": -1, "definition": 1e308, "smoothness": 0})),
|
||||
PixelFx::Sponge { brush: 0.0, definition: 25.0, smoothness: 1.0 }
|
||||
);
|
||||
let (w, h) = (80, 60);
|
||||
let flat = image(w, h, |_, y| if y < 50 { [200, 90, 60, 255] } else { [100, 45, 30, 128] });
|
||||
let out = run("artistic.sponge", json!({"definition": 25}), &flat, w, h);
|
||||
assert_eq!(out, run("artistic.sponge", json!({"definition": 25}), &flat, w, h), "draws the same twice");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(flat.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
|
||||
// A flat colour breaks up into lighter and darker blobs of the same hue.
|
||||
let top = &out[..w * 50 * 4];
|
||||
assert!(spread(top) > 15.0, "{}", spread(top));
|
||||
assert!(top.as_chunks::<4>().0.iter().all(|p| p[0] >= p[1] && p[1] >= p[2]), "red stays the strongest channel");
|
||||
// Definition 0 leaves a flat colour as it is; more definition, more contrast.
|
||||
let none = run("artistic.sponge", json!({"definition": 0}), &flat, w, h);
|
||||
assert!(spread(&none[..w * 50 * 4]) < 1.0);
|
||||
let some = run("artistic.sponge", json!({"definition": 8}), &flat, w, h);
|
||||
assert!(spread(&some[..w * 50 * 4]) < spread(top));
|
||||
survives("artistic.sponge", &[json!({}), json!({"brushSize": 1e308, "definition": 1e308, "smoothness": 1e308}), json!({"brushSize": "NaN"})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watercolor_simplifies_detail_and_saturates_edges() {
|
||||
assert_eq!(fx("artistic.watercolor", json!({})), PixelFx::Watercolor { detail: 9.0, shadow: 1.0, texture: 1 });
|
||||
assert_eq!(
|
||||
fx("artistic.watercolor", json!({"brushDetail": 99, "shadowIntensity": -1, "texture": 2.6})),
|
||||
PixelFx::Watercolor { detail: 14.0, shadow: 0.0, texture: 3 }
|
||||
);
|
||||
// Noisy grey on the left, a step from light red to dark blue on the right, a transparent row.
|
||||
let (w, h) = (64, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
if y >= 44 {
|
||||
return [0; 4];
|
||||
}
|
||||
if x < 32 {
|
||||
grey(if (x * 7 + y * 13) % 5 < 2 { 90 } else { 150 })
|
||||
} else if x < 48 {
|
||||
[200, 120, 120, 255]
|
||||
} else {
|
||||
[60, 70, 110, 255]
|
||||
}
|
||||
});
|
||||
let few = run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h);
|
||||
let many = run("artistic.watercolor", json!({"brushDetail": 14}), &src, w, h);
|
||||
assert_eq!(few, run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h), "draws the same twice");
|
||||
assert!(few.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept, nothing painted outside");
|
||||
assert!(roughness(&few, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28), "less detail, simpler");
|
||||
assert!(roughness(&few, w, 4, 28) < roughness(&many, w, 4, 28));
|
||||
// Colour is saturated where the tones change, and shadows deepen with Shadow Intensity.
|
||||
let chroma = |d: &[u8], x: usize, y: usize| {
|
||||
let i = (y * w + x) * 4;
|
||||
f64::from(d[i].max(d[i + 1]).max(d[i + 2])) - f64::from(d[i].min(d[i + 1]).min(d[i + 2]))
|
||||
};
|
||||
assert!(chroma(&few, 47, 20) > chroma(&src, 47, 20) || chroma(&few, 48, 20) > chroma(&src, 48, 20));
|
||||
assert!(mean(&run("artistic.watercolor", json!({"shadowIntensity": 10}), &src, w, h), w, 4, 28) < mean(&few, w, 4, 28));
|
||||
// Texture adds paper grain to a flat colour.
|
||||
let flat = image(w, h, |_, _| grey(128));
|
||||
let (t1, t3) = (run("artistic.watercolor", json!({"texture": 1}), &flat, w, h), run("artistic.watercolor", json!({"texture": 3}), &flat, w, h));
|
||||
assert!(spread(&t3) > spread(&t1) && spread(&t1) > 0.2);
|
||||
survives(
|
||||
"artistic.watercolor",
|
||||
&[json!({}), json!({"brushDetail": -1e308, "shadowIntensity": 1e308, "texture": 1e308}), json!({"brushDetail": "NaN"})],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(w / 2.0, h / 2.0),
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * w.hypot(h),
|
||||
}
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => p.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx(id, p).apply(&mut out, w, h, &space(w, h));
|
||||
out
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
/// Channel 0 of pixel (`x`, `y`).
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> u8 {
|
||||
d[(y * w + x) * 4]
|
||||
}
|
||||
|
||||
/// How many pixels of channel 0 are darker than 128.
|
||||
fn black(d: &[u8]) -> usize {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count()
|
||||
}
|
||||
|
||||
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
|
||||
fn survives(id: &str, params: &[serde_json::Value]) {
|
||||
let (w, h) = (9, 7);
|
||||
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
|
||||
for p in params {
|
||||
for s in [tiny, huge, nan, space(w, h)] {
|
||||
let mut out = src.clone();
|
||||
fx(id, p.clone()).apply(&mut out, w, h, &s);
|
||||
assert_eq!(out.len(), src.len());
|
||||
let alphas = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| p[3]).collect::<Vec<_>>();
|
||||
if s.px == 1.0 {
|
||||
assert_eq!(alphas(&out), alphas(&src), "{id}: coverage kept");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A size that doesn't match, and an empty raster, do nothing.
|
||||
let mut odd = vec![1u8; 10];
|
||||
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
|
||||
assert_eq!(odd, vec![1u8; 10]);
|
||||
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photocopy_copies_the_edges_of_dark_areas() {
|
||||
assert_eq!(fx("sketch.photocopy", json!({})), PixelFx::Photocopy { detail: 7.0, darkness: 25.0 });
|
||||
assert_eq!(fx("sketch.photocopy", json!({"detail": 1e308, "darkness": -5})), PixelFx::Photocopy { detail: 24.0, darkness: 1.0 });
|
||||
let (w, h) = (64, 16);
|
||||
let src = image(w, h, |x, _| grey(if x < 32 { 30 } else { 230 }));
|
||||
let out = run("sketch.photocopy", json!({"detail": 20}), &src, w, h);
|
||||
// Black just on the dark side of the edge; white in the dark area's middle and in the light.
|
||||
assert!(at(&out, w, 30, 8) < 64, "edge {}", at(&out, w, 30, 8));
|
||||
assert!(at(&out, w, 6, 8) > 192, "dark area {}", at(&out, w, 6, 8));
|
||||
assert!(at(&out, w, 50, 8) > 192, "light area {}", at(&out, w, 50, 8));
|
||||
assert_eq!(out, run("sketch.photocopy", json!({"detail": 20}), &src, w, h), "deterministic");
|
||||
// A higher Darkness copies more.
|
||||
let more = run("sketch.photocopy", json!({"detail": 20, "darkness": 50}), &src, w, h);
|
||||
let less = run("sketch.photocopy", json!({"detail": 20, "darkness": 1}), &src, w, h);
|
||||
assert!(black(&more) >= black(&less));
|
||||
survives("sketch.photocopy", &[json!({}), json!({"detail": 1e308, "darkness": "NaN"}), json!({"detail": -1e308, "darkness": [1]})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_prints_solid_black_and_white() {
|
||||
assert_eq!(fx("sketch.stamp", json!({})), PixelFx::Stamp { balance: 25.0, smoothness: 5.0 });
|
||||
assert_eq!(fx("sketch.stamp", json!({"lightDarkBalance": 1e308, "smoothness": -1})), PixelFx::Stamp { balance: 50.0, smoothness: 1.0 });
|
||||
let (w, h) = (64, 8);
|
||||
let src = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
let few = run("sketch.stamp", json!({"lightDarkBalance": 0}), &src, w, h);
|
||||
let many = run("sketch.stamp", json!({"lightDarkBalance": 50}), &src, w, h);
|
||||
assert!(black(&many) > black(&few), "{} vs {}", black(&many), black(&few));
|
||||
// Pure black and white, but for the one pixel wide edges.
|
||||
let mixed = many.as_chunks::<4>().0.iter().filter(|p| p[0] != 0 && p[0] != 255).count();
|
||||
assert!(mixed * 10 < many.len() / 4, "{mixed} grey pixels");
|
||||
assert_eq!(at(&many, w, 0, 4), 0);
|
||||
assert_eq!(at(&many, w, 63, 4), 255);
|
||||
survives(
|
||||
"sketch.stamp",
|
||||
&[json!({}), json!({"lightDarkBalance": "NaN", "smoothness": 1e308}), json!({"lightDarkBalance": -1e308, "smoothness": null})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charcoal_draws_bold_edges_and_sketches_the_midtones() {
|
||||
assert_eq!(fx("sketch.charcoal", json!({})), PixelFx::Charcoal { thickness: 1.0, detail: 1.0, balance: 50.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.charcoal", json!({"thickness": 1e308, "detail": -5, "lightDarkBalance": "x"})),
|
||||
PixelFx::Charcoal { thickness: 7.0, detail: 0.0, balance: 50.0 }
|
||||
);
|
||||
let (w, h) = (64, 48);
|
||||
// Black on the left, white on the right: a bold line at the edge, nothing in the white.
|
||||
let step = image(w, h, |x, _| grey(if x < 32 { 0 } else { 255 }));
|
||||
let out = run("sketch.charcoal", json!({}), &step, w, h);
|
||||
assert!((28..36).any(|x| at(&out, w, x, 24) < 64), "edge");
|
||||
assert!((48..64).all(|x| at(&out, w, x, 24) == 255), "white stays white");
|
||||
// A midtone is sketched in strokes, more of it the more the balance reaches into the lights.
|
||||
let mid = image(w, h, |_, _| grey(128));
|
||||
let sketched = run("sketch.charcoal", json!({}), &mid, w, h);
|
||||
let (n, total) = (black(&sketched), w * h);
|
||||
assert!(n > total / 50 && n < total * 9 / 10, "{n} of {total}");
|
||||
let darker = run("sketch.charcoal", json!({"lightDarkBalance": 100}), &mid, w, h);
|
||||
let lighter = run("sketch.charcoal", json!({"lightDarkBalance": 0}), &mid, w, h);
|
||||
assert!(black(&darker) > black(&lighter));
|
||||
assert_eq!(sketched, run("sketch.charcoal", json!({}), &mid, w, h), "deterministic");
|
||||
let white = image(w, h, |_, _| grey(255));
|
||||
assert_eq!(black(&run("sketch.charcoal", json!({"lightDarkBalance": 0}), &white, w, h)), 0);
|
||||
survives(
|
||||
"sketch.charcoal",
|
||||
&[json!({}), json!({"thickness": 1e308, "detail": "NaN", "lightDarkBalance": [1]}), json!({"thickness": -1e308, "detail": 1e308})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bas_relief_lights_the_surfaces_facing_the_light() {
|
||||
assert_eq!(fx("sketch.basRelief", json!({})), PixelFx::BasRelief { detail: 13.0, smoothness: 3.0, light: Light::Bottom });
|
||||
assert_eq!(
|
||||
fx("sketch.basRelief", json!({"detail": 0, "smoothness": 1e308, "lightDirection": "Top"})),
|
||||
PixelFx::BasRelief { detail: 1.0, smoothness: 15.0, light: Light::Top }
|
||||
);
|
||||
// Dark above, light below: the edge is lit from one side and shaded from the other.
|
||||
let (w, h) = (16, 48);
|
||||
let src = image(w, h, |_, y| grey(if y < 24 { 20 } else { 235 }));
|
||||
let below = run("sketch.basRelief", json!({"lightDirection": "bottom"}), &src, w, h);
|
||||
let above = run("sketch.basRelief", json!({"lightDirection": "top"}), &src, w, h);
|
||||
let edge = (0..h).map(|y| (y, i32::from(at(&below, w, 8, y)) - i32::from(at(&above, w, 8, y)))).max_by_key(|(_, d)| d.abs());
|
||||
assert!(edge.is_some_and(|(_, d)| d.abs() > 100), "{edge:?}");
|
||||
// Flat areas are grey.
|
||||
for y in [2, 45] {
|
||||
let v = at(&below, w, 8, y);
|
||||
assert!((90..190).contains(&v), "flat {v}");
|
||||
}
|
||||
survives(
|
||||
"sketch.basRelief",
|
||||
&[json!({}), json!({"detail": "NaN", "smoothness": 1e308, "lightDirection": 3}), json!({"detail": -1e308, "smoothness": null})],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
|
||||
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] {
|
||||
let f = fx(id, json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
|
||||
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ impl Light {
|
||||
}
|
||||
|
||||
/// The unit vector towards the light (document axes, y down).
|
||||
fn toward(self) -> Vec2 {
|
||||
pub(super) fn toward(self) -> Vec2 {
|
||||
let d = FRAC_1_SQRT_2;
|
||||
match self {
|
||||
Light::Bottom => Vec2::new(0.0, 1.0),
|
||||
@@ -269,7 +269,7 @@ impl Tones {
|
||||
}
|
||||
|
||||
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
|
||||
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
|
||||
pub(super) fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
|
||||
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
|
||||
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
|
||||
// Per pixel → per document unit: through the transpose of the document → pixel map.
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
|
||||
/// as the glows AI files export measure.
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
@@ -93,6 +95,10 @@ impl RasterFx {
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
RasterFx::OuterGlow { .. } => {
|
||||
let o = self.outset(bounds) + 0.25 * line;
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
|
||||
+107
-19
@@ -7,12 +7,14 @@
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
|
||||
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
|
||||
//! them ([`image_rows`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
@@ -44,11 +46,75 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
|
||||
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or
|
||||
/// Outline Object)?
|
||||
fn gives_shape(e: &Effect) -> bool {
|
||||
e.visible
|
||||
&& matches!(e.id.as_str(), "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" | "path.outlineObject")
|
||||
}
|
||||
|
||||
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
|
||||
/// that gives it a shape ([`gives_shape`])?
|
||||
fn shapes_image(item: &AppearanceItem) -> bool {
|
||||
crate::paints(item) && item.effects().iter().any(gives_shape)
|
||||
}
|
||||
|
||||
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
|
||||
/// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes
|
||||
/// its shape from the image's box, and Outline Object gives the image's frame. The others paint
|
||||
/// nothing.
|
||||
fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|i| shapes_image(i))
|
||||
.map(|i| {
|
||||
let mut i = i.clone();
|
||||
let fx = i.effects_mut();
|
||||
let first = fx.iter().position(gives_shape).unwrap_or(0);
|
||||
fx.drain(..first);
|
||||
i
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
|
||||
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
|
||||
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
|
||||
/// them. Empty for anything but an image with such fills or strokes.
|
||||
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(art) = reshape(n, None) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
|
||||
let Some(g) = c.stroke_path() else { return };
|
||||
let ctx = GeomContext::of(c);
|
||||
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
let fx = item.effects();
|
||||
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
|
||||
crate::apply_geometry_bez(fx, &g, g.bounding_box(), &ctx.item(item))
|
||||
} else {
|
||||
g.clone()
|
||||
};
|
||||
out.push((shape, *rule, item.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
@@ -186,11 +252,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
|
||||
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
|
||||
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
|
||||
/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and
|
||||
/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline
|
||||
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
|
||||
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
@@ -200,14 +268,17 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
let own_paint = match n.kind {
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty(),
|
||||
_ => has_item_geometry(n),
|
||||
};
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
}
|
||||
@@ -220,12 +291,28 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
let mut clip = Node::path(n.id, frame, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, n.kind.clone());
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
|
||||
let clipped = || {
|
||||
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true })
|
||||
};
|
||||
// The fills and strokes that paint a shape, as paths along the frame below and above
|
||||
// the image.
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let (below, above) = (image_rows(below), image_rows(above));
|
||||
if below.is_empty() && above.is_empty() {
|
||||
clipped()
|
||||
} else {
|
||||
let rows = |items: Vec<AppearanceItem>| {
|
||||
(!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() })))
|
||||
};
|
||||
// Without geometry effects, the image is not clipped.
|
||||
let image = if fx.is_empty() { image.clone() } else { clipped() };
|
||||
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
|
||||
}
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
};
|
||||
@@ -234,7 +321,8 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if let Some(h) = n.projection() {
|
||||
project_leaves(&mut art, &h);
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
|
||||
// group).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.visible = n.visible;
|
||||
|
||||
@@ -212,8 +212,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
|
||||
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
||||
/// otherwise each solid is a surface of its own, in that order.
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
@@ -229,12 +233,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
|
||||
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
|
||||
.map(|(_, i)| {
|
||||
let (area, rule) = region(path, rule, i);
|
||||
(*i, area, rule)
|
||||
})
|
||||
.collect();
|
||||
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
|
||||
// and Inflate's grid reaches over the strokes' outlines.
|
||||
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, _, _), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) => solids.push((item, f)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path
|
||||
// instead of failing the frame; commands validate the same evaluator and report its
|
||||
// errors.
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, _)| {
|
||||
let (_, opacity, blend) = paint_of(item);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node)> = solids
|
||||
.into_iter()
|
||||
.map(|(item, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
(depths, surface(n, f, &paint, opacity, blend, colored))
|
||||
})
|
||||
.collect();
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. With
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
|
||||
}
|
||||
let mut faces = vec![];
|
||||
for (depths, s) in surfaces {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
faces.extend(depths.into_iter().zip(children));
|
||||
}
|
||||
}
|
||||
// A stable sort: faces at the same depth keep the order of the appearance.
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||||
assert!(!faces(&art).is_empty(), "{id}");
|
||||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||||
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
|
||||
// hue.
|
||||
for f in faces(&art) {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||||
@@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
// Extrude & Bevel paints one surface with the top fill.
|
||||
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
|
||||
|
||||
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
|
||||
fn stroked(b: &BezPath) -> Node {
|
||||
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
|
||||
}
|
||||
|
||||
/// A `width` × 100 pt rectangle at (100, 100), stroked.
|
||||
fn stroked_rect(width: f64) -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((100.0 + width, 100.0));
|
||||
b.line_to((100.0 + width, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
stroked(&b)
|
||||
}
|
||||
|
||||
/// Is `face` in the stroke's blue (rather than the fill's red)?
|
||||
fn blue(face: &Node) -> bool {
|
||||
let [r, _, b] = fill_rgb(face);
|
||||
b > r
|
||||
}
|
||||
|
||||
/// The bounds of the faces' own paths (without their seam strokes).
|
||||
fn extent(faces: &[&Node]) -> Rect {
|
||||
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
|
||||
}
|
||||
|
||||
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
|
||||
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
|
||||
}
|
||||
|
||||
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
|
||||
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
|
||||
/// fill's, and they are drawn in the stroke's paint.
|
||||
#[test]
|
||||
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
|
||||
assert!(!strokes.is_empty(), "{id}: the stroke draws");
|
||||
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
|
||||
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
|
||||
// Its own blue, shaded: never brighter, never another hue.
|
||||
for f in &strokes {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
|
||||
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
|
||||
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
|
||||
/// drawn below the fill's.
|
||||
#[test]
|
||||
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
|
||||
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
|
||||
// caps; the mean of its outline's points is not the mean of the stroke's.
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((400.0, 100.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
|
||||
let mut n = stroked(&b);
|
||||
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
|
||||
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let flat = params["depth"] == json!(0);
|
||||
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
|
||||
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
|
||||
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
|
||||
// The stroke's cap is a ring: its outline and the hole the fill shows through.
|
||||
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
|
||||
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
|
||||
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
|
||||
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
|
||||
if !flat {
|
||||
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
|
||||
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
|
||||
#[test]
|
||||
fn a_stroked_open_path_builds_a_ribbon() {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
assert!(faces.iter().all(|f| blue(f)), "{id}");
|
||||
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
|
||||
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
|
||||
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
|
||||
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
|
||||
/// still draws.
|
||||
#[test]
|
||||
fn a_stroke_past_the_limits_is_left_out() {
|
||||
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..1500)
|
||||
.map(|k| {
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
|
||||
Point::new(200.0 + r * c, 200.0 + r * s)
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
|
||||
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
|
||||
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
|
||||
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
|
||||
#[test]
|
||||
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
|
||||
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
|
||||
// 1 pt stroke, so the stroke stays flat.
|
||||
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
|
||||
let mut n = stroked_rect(length);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.width = width;
|
||||
}
|
||||
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
|
||||
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
|
||||
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
|
||||
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
|
||||
#[test]
|
||||
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
|
||||
assert!(!blue(fill_back) && blue(stroke_back));
|
||||
let fill = fill_back.path_data().unwrap().bounds().unwrap();
|
||||
// The ring's inner contour: the smaller of its two.
|
||||
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
|
||||
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
|
||||
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
|
||||
}
|
||||
|
||||
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
|
||||
/// opacity applies to the stroke as a whole.
|
||||
#[test]
|
||||
fn a_translucent_stroke_is_a_surface_of_its_own() {
|
||||
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.opacity = opacity;
|
||||
s.blend = blend;
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
|
||||
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
|
||||
}
|
||||
}
|
||||
|
||||
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
|
||||
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
|
||||
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
|
||||
#[test]
|
||||
fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.paint = Paint::Gradient(Box::new(gradient.clone()));
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let drawn = art.children().unwrap();
|
||||
assert_eq!(drawn.len(), surfaces, "{lights}");
|
||||
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
@@ -230,9 +448,9 @@ fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||||
@@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
assert_eq!(clips, 1);
|
||||
assert_eq!(groups, 7, "group count must not grow with triangle count");
|
||||
// The fill's solid and the stroke's, each one composite with its own clip.
|
||||
let surfaces = art.children().unwrap().len();
|
||||
assert_eq!(surfaces, 2);
|
||||
assert_eq!(clips, surfaces);
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
|
||||
}
|
||||
@@ -15,7 +15,9 @@
|
||||
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
|
||||
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
|
||||
//! paint below the members and which above ([`Appearance::contents_at`], moved by
|
||||
//! `appearance.moveItem {from: "contents"}`).
|
||||
//! `appearance.moveItem {from: "contents"}`). An image's own fills and strokes paint only the
|
||||
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
|
||||
//! image as its Contents row says; new ones go right below that row.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
@@ -36,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -45,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -408,6 +410,9 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
|
||||
let defaults = s.paint.clone();
|
||||
// Where the new item went in on the active item's object, when it is an image.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let mut inserted = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -429,11 +434,23 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
} else {
|
||||
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
|
||||
};
|
||||
n.appearance.items.push(item);
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
// On an image it goes right below the Contents row (the image).
|
||||
let k = n.appearance.contents_at();
|
||||
n.appearance.items.insert(k, item);
|
||||
n.appearance.set_contents_at(k + 1);
|
||||
if active == Some(*id) {
|
||||
inserted = Some(k);
|
||||
}
|
||||
} else {
|
||||
n.appearance.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The rows above the new item move up by one.
|
||||
s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a))));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -478,7 +495,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
|
||||
ap.rebase_gradients(f, t);
|
||||
}
|
||||
// Only groups, layers and type have a Contents (Characters) row to keep a slot for.
|
||||
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
|
||||
if to.contents_label().is_none() {
|
||||
ap.contents_index = None;
|
||||
}
|
||||
@@ -717,7 +734,7 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||
n.appearance.set_contents_at(to);
|
||||
slot.get_or_insert(n.appearance.contents_at());
|
||||
}
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
|
||||
Ok(count_changed(d, &before, ids))
|
||||
})?;
|
||||
Ok(json!({ "contents": slot }))
|
||||
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -30,6 +30,10 @@ pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// A `.ai` or `.ait` file whose bytes are a PDF, copied in a file manager ([`file_flavour`]): Paste
|
||||
/// reads it as `document.open` does, from its editing data. The type is VectorCraft's own: no
|
||||
/// system clipboard carries it, and Paste asks for [`PDF`] to get it.
|
||||
pub const AI: &str = "application/x-ai";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
@@ -71,8 +75,9 @@ pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
|
||||
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file is [`AI`] when its bytes are a PDF (it
|
||||
/// pastes when PDF is asked for) and EPS when they are PostScript. The file's bytes are that
|
||||
/// flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
@@ -83,7 +88,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
"pdf" => (PDF, PDF),
|
||||
"ps" => (EPS, EPS),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, AI),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
@@ -122,6 +127,13 @@ pub struct Flavour {
|
||||
const COPY_PNG_SIDE: f64 = 4096.0;
|
||||
/// Most bytes of text `clipboard.importText` takes.
|
||||
const MAX_TEXT: usize = 1 << 20;
|
||||
/// Most bytes of images the SVG markup may embed and still go out as plain text. A paste reads the
|
||||
/// clipboard's text first (eframe reads it on every paste key; on Linux and macOS it is the one
|
||||
/// format the app writes, and checking that the clipboard still holds our copy reads it again), so
|
||||
/// a placed photo in it made each paste move megabytes of base64 through the system clipboard on
|
||||
/// the UI thread, and on a GNOME desktop the app stopped responding (#2213). A copy with more image
|
||||
/// data than this offers its SVG under its own type and its PNG, not as text.
|
||||
const COPY_TEXT_IMAGE_BYTES: usize = 1 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -157,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clipboard Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
|
||||
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg when the images it embeds total at most 1 MiB), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
|
||||
always,
|
||||
flavours
|
||||
),
|
||||
@@ -184,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load PDF into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), center?: [x, y]} replace the clipboard with the objects of one PDF page (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_pdf
|
||||
),
|
||||
@@ -202,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use; a hidden layer's objects hidden), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
@@ -263,12 +275,17 @@ impl Clipboard {
|
||||
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// Bytes of the encoded images the objects show (what their SVG embeds as base64).
|
||||
fn image_bytes(&self) -> usize {
|
||||
self.images.values().fold(0usize, |sum, b| sum.saturating_add(b.bytes.len()))
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
|
||||
if self.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let text = svg || self.nodes.iter().all(type_only);
|
||||
let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only);
|
||||
// Art without an area (a lone guide-like line) has no page or picture.
|
||||
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
|
||||
@@ -452,8 +469,19 @@ fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
import_loaded(s, p, cmd, loaded)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with every object of `loaded`, a file read as `document.open` reads it
|
||||
/// (command `cmd`). The layers are left out, and the objects of a hidden layer come in hidden.
|
||||
fn import_loaded(s: &mut Session, p: &Value, cmd: &str, mut loaded: fileio::Loaded) -> Result<Value> {
|
||||
loaded.doc.drop_edit_modes();
|
||||
for layer in loaded.doc.layers.iter_mut().filter(|l| !l.visible) {
|
||||
for object in std::sync::Arc::make_mut(layer).children_mut().into_iter().flatten() {
|
||||
std::sync::Arc::make_mut(object).visible = false;
|
||||
}
|
||||
}
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
@@ -469,7 +497,19 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(C, "page must be a whole number from 1"))? as usize,
|
||||
};
|
||||
let editing = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "editingData must be true or false"))?,
|
||||
};
|
||||
let opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() };
|
||||
// A copied .ai file pastes every object of its editing data, as document.open reads the file.
|
||||
// When document.open would not use that copy, the file pastes its page, as a PDF does.
|
||||
if editing
|
||||
&& let Ok(loaded) = fileio::load_with("Clipboard.ai", &bytes, &opts)
|
||||
&& (loaded.editing_data || loaded.restored)
|
||||
{
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
|
||||
+124
-12
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
match &mut n.kind {
|
||||
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
}
|
||||
|
||||
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
|
||||
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
|
||||
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
|
||||
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
|
||||
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
|
||||
use kurbo::ParamCurveArclen;
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let (si, _, _, _, d_a) = pd.nearest(first)?;
|
||||
let (sj, _, _, _, d_b) = pd.nearest(last)?;
|
||||
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut sp = pd.subpaths.get(si)?.clone();
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
|
||||
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
|
||||
// new ones, which a `t` along the old one doesn't measure.
|
||||
let place = |sp: &mut SubPath, q: Point| {
|
||||
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
|
||||
let n = sp.anchors.len();
|
||||
Some(if t <= 1e-6 {
|
||||
(seg % n, false)
|
||||
} else if t >= 1.0 - 1e-6 {
|
||||
((seg + 1) % n, false)
|
||||
} else {
|
||||
(sp.insert_anchor(seg, t), true)
|
||||
})
|
||||
};
|
||||
let (mut from, _) = place(&mut sp, first)?;
|
||||
let (to, added) = place(&mut sp, last)?;
|
||||
if added && to <= from {
|
||||
from += 1;
|
||||
}
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// The length of the run of segments from anchor i forward to anchor j.
|
||||
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
|
||||
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
|
||||
// `end` round to `start`, then the stroke's inner anchors follow.
|
||||
let (start, end, reversed) = if sp.closed {
|
||||
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
|
||||
} else if from < to {
|
||||
(from, to, false)
|
||||
} else {
|
||||
(to, from, true)
|
||||
};
|
||||
let mut stroke = pts.to_vec();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
|
||||
*s0 = s1.p;
|
||||
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
|
||||
*e0 = e1.p;
|
||||
let fit = fit_freehand(&stroke, tol, false).anchors;
|
||||
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
|
||||
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
|
||||
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
|
||||
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
|
||||
} else {
|
||||
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
|
||||
};
|
||||
let mut anchors = before;
|
||||
if let Some(a) = anchors.last_mut() {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
|
||||
}
|
||||
anchors.extend_from_slice(inner);
|
||||
let at_end = anchors.len();
|
||||
anchors.extend(after);
|
||||
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
|
||||
let end_at = if sp.closed { 0 } else { at_end };
|
||||
if let Some(a) = anchors.get_mut(end_at) {
|
||||
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
|
||||
}
|
||||
Some((si, SubPath::new(anchors, sp.closed)))
|
||||
}
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = f64_or(rd, "within", 12.0);
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
}
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
|
||||
// it to the new length if Calligraphic pressure strokes get redrawn.
|
||||
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -863,7 +975,7 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
|
||||
@@ -408,6 +408,7 @@ fn expandable(n: &Node) -> bool {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
_ => !n.appearance.effects.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
@@ -363,15 +363,16 @@ fn reshaped(l: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
|
||||
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
|
||||
/// as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
|
||||
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
|
||||
/// `path` as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
if !has_geometry(&f.effects) {
|
||||
if !has_geometry(item.effects()) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
}
|
||||
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
|
||||
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
|
||||
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
}
|
||||
@@ -432,7 +433,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
(!t.is_empty()).then_some((f, fl, t))
|
||||
});
|
||||
|
||||
@@ -411,6 +411,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
if let Some(plugin) = vectorcraft_plugins::export::by_format(f.id) {
|
||||
return plugin_file(doc, &plugin, f, p);
|
||||
}
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
|
||||
@@ -432,6 +435,32 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// The file exporter plug-in `plugin` writes of `doc` in its format `f`
|
||||
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
|
||||
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
|
||||
/// one named after the document) with that path's extension when the plug-in lists it, else its
|
||||
/// first. Its warnings come back with the file.
|
||||
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
use vectorcraft_plugins::{Error, export};
|
||||
let doc = &*doc.without_edit_modes();
|
||||
let file = vectorcraft_format::save_with(doc, &vectorcraft_format::SaveOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let path = p.get("path").and_then(Value::as_str);
|
||||
let listed = path.map(super::extension).and_then(|e| f.extensions.iter().copied().find(|x| *x == e));
|
||||
let extension = listed.or(f.extensions.first().copied()).unwrap_or_default();
|
||||
let name = super::file_stem(path.unwrap_or(&super::default_name(doc, extension)));
|
||||
let params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
let target = export::Target { extension, name: &name };
|
||||
let out = export::run(plugin, vectorcraft_format::json_part(&file), ¶ms, target).map_err(|e| match e {
|
||||
Error::Cancelled => EngineError::Cancelled,
|
||||
Error::Params(why) => bad(C, why),
|
||||
e => EngineError::Other(e.to_string()),
|
||||
})?;
|
||||
// The plug-in's output, so reported as its other output errors are.
|
||||
let invalid = || EngineError::Other(Error::Output("`dataBase64` is not valid base64".into()).to_string());
|
||||
let bytes = vectorcraft_format::base64_decode(&out.data_base64).ok_or_else(invalid)?;
|
||||
Ok(Encoded { warnings: out.warnings, ..Encoded::one(bytes) })
|
||||
}
|
||||
|
||||
/// [`encode_all`] without the font substitution warning.
|
||||
fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let doc = &*doc.without_edit_modes();
|
||||
|
||||
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if transparent && flat.is_some() {
|
||||
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for (board, region, origin) in pages {
|
||||
let rect = vectorcraft_eps::preview_rect(region, origin);
|
||||
|
||||
@@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Loaded {
|
||||
/// Only a stand-in picture of the file (an Affinity document whose native data couldn't be
|
||||
/// read): Open shows it with its warning; Place, templates and libraries refuse it.
|
||||
pub preview_only: bool,
|
||||
/// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art
|
||||
/// that it carries (its layers), not from its PDF part alone.
|
||||
pub editing_data: bool,
|
||||
}
|
||||
|
||||
impl Loaded {
|
||||
@@ -156,17 +159,17 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
|
||||
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
|
||||
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
|
||||
/// first page's `UserUnit`).
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>) {
|
||||
/// first page's `UserUnit`) → (document, warnings, whether its layers were read).
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>, bool) {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return (doc, warnings);
|
||||
return (doc, warnings, false);
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings, false) };
|
||||
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit);
|
||||
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
return (doc, warnings, false);
|
||||
}
|
||||
// The layers' text objects hold type and the stroked paths around it, which can include stroked
|
||||
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
|
||||
@@ -177,7 +180,7 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
|
||||
// to the page.
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
|
||||
(doc, warnings)
|
||||
(doc, warnings, true)
|
||||
}
|
||||
|
||||
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
|
||||
@@ -194,6 +197,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
|
||||
)
|
||||
}
|
||||
|
||||
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
|
||||
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
|
||||
let Some(range) = &opts.pages else { return true };
|
||||
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -203,6 +212,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let mut editing_data = false;
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
@@ -237,11 +247,20 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// Pages of text alone: the editing copy alone where it reads, as for a file
|
||||
// saved without its PDF part (#1387: else the page, which is the art).
|
||||
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
|
||||
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
|
||||
let (doc, warnings, read) = match alone {
|
||||
Some((doc, warnings)) => (doc, warnings, true),
|
||||
None => through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit),
|
||||
};
|
||||
editing_data = read;
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts })
|
||||
.unwrap_or(Err(e))
|
||||
.inspect(|_| editing_data = true)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
@@ -287,7 +306,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
|
||||
}
|
||||
|
||||
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
|
||||
@@ -481,6 +500,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A Targa file, which starts with no signature, is recognized by its header
|
||||
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
|
||||
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
|
||||
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
@@ -493,6 +516,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
|
||||
reader.set_format(image::ImageFormat::Tga);
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -511,13 +537,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
|
||||
}
|
||||
},
|
||||
};
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod tgaread;
|
||||
pub mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -125,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -197,7 +198,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
|
||||
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.<plug-in id>; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
|
||||
always,
|
||||
formats
|
||||
),
|
||||
@@ -206,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"PDF Info",
|
||||
[],
|
||||
None,
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
|
||||
always,
|
||||
pdfimport::pdf_info
|
||||
),
|
||||
@@ -646,11 +647,20 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
extensions: &["tga", "vda", "icb", "vst"],
|
||||
mime: "image/x-tga",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: TGA_OPTIONS,
|
||||
},
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
@@ -674,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -688,17 +702,19 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"afpub",
|
||||
];
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
|
||||
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
|
||||
/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop
|
||||
/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft
|
||||
/// for it.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
pub fn export_extensions() -> Vec<&'static str> {
|
||||
let mut v: Vec<&'static str> = Vec::new();
|
||||
for e in FORMATS.iter().filter(|f| f.write).filter_map(|f| f.extensions.first()) {
|
||||
let added = plugin_formats();
|
||||
for e in FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write).filter_map(|f| f.extensions.first()) {
|
||||
if !v.contains(e) {
|
||||
v.push(e);
|
||||
}
|
||||
@@ -733,6 +749,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -781,10 +801,47 @@ pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Word", DOCX_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
|
||||
/// one, else one an exporter plug-in adds ([`plugin_formats`]). A built-in format keeps its
|
||||
/// extension, so an exporter that lists it too is found by its format id.
|
||||
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
|
||||
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str())))
|
||||
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| {
|
||||
let added = plugin_formats();
|
||||
added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied()
|
||||
})
|
||||
}
|
||||
|
||||
/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in
|
||||
/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for
|
||||
/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues
|
||||
/// that hold them.
|
||||
pub fn plugin_formats() -> Vec<&'static Format> {
|
||||
use vectorcraft_plugins::{export, registry};
|
||||
static MADE: std::sync::Mutex<Vec<(u64, &'static Format)>> = std::sync::Mutex::new(Vec::new());
|
||||
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
export::installed()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) {
|
||||
return *f;
|
||||
}
|
||||
let m = p.manifest();
|
||||
let extensions: Vec<&'static str> = m.extensions.iter().map(|e| registry::intern(e)).collect();
|
||||
let f: &'static Format = Box::leak(Box::new(Format {
|
||||
id: registry::intern(&export::format_id(&m.id)),
|
||||
label: registry::intern(&m.name),
|
||||
extensions: Box::leak(extensions.into_boxed_slice()),
|
||||
mime: "application/octet-stream",
|
||||
read: false,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
}));
|
||||
made.push((p.serial(), f));
|
||||
f
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The lower-case extension of a file name or path (empty when it has none).
|
||||
@@ -839,9 +896,18 @@ fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result
|
||||
}
|
||||
|
||||
fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
|
||||
let added = plugin_formats();
|
||||
let all = || FORMATS.iter().chain(added.iter().copied());
|
||||
let ids = |pick: fn(&Format) -> bool| all().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
|
||||
let listed = |f: &Format| {
|
||||
let mut v = f.to_json();
|
||||
if let Some(id) = vectorcraft_plugins::export::plugin_id(f.id) {
|
||||
v["plugin"] = json!(id);
|
||||
}
|
||||
v
|
||||
};
|
||||
Ok(json!({
|
||||
"formats": FORMATS.iter().map(Format::to_json).collect::<Vec<_>>(),
|
||||
"formats": all().map(listed).collect::<Vec<_>>(),
|
||||
"readable": ids(|f| f.read),
|
||||
"writable": ids(|f| f.write),
|
||||
"openExtensions": OPEN_EXTS,
|
||||
@@ -1085,14 +1151,74 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. Every stored path reaches the file system through this.
|
||||
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
|
||||
/// ([`share_path`]): a path refused as on another computer is checked again against the
|
||||
/// document's path on that share. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
document_path_with(stored, document, share_path)
|
||||
}
|
||||
|
||||
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
|
||||
pub(crate) fn document_path_with(
|
||||
stored: &str,
|
||||
document: Option<&str>,
|
||||
share: impl FnOnce(&str) -> Option<String>,
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
use craft_docpath::Refused;
|
||||
let document = document.map(absolute_path);
|
||||
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
|
||||
let path = match resolve(document.as_deref()) {
|
||||
Err(Refused::Network) => {
|
||||
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
|
||||
resolve(Some(share.as_str()))?
|
||||
}
|
||||
path => path?,
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(craft_docpath::Refused::Invalid);
|
||||
return Err(Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
|
||||
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
|
||||
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
|
||||
/// `\\?\UNC\server\share\…`.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn share_path(document: &str) -> Option<String> {
|
||||
share_of(document, |folder| {
|
||||
may_read(folder).ok()?;
|
||||
std::fs::canonicalize(folder).ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn share_path(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
|
||||
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
|
||||
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
|
||||
let mut chars = document.chars();
|
||||
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// The drive's root folder keeps its separator (`Z:\`).
|
||||
let split = document.rfind(['\\', '/'])?;
|
||||
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = final_path(folder)?;
|
||||
let target = target.to_str()?;
|
||||
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
|
||||
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(r"\\{unc}\{name}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
|
||||
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
|
||||
//! boxes, a thumbnail) for the Import PDF dialog.
|
||||
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
@@ -143,13 +143,13 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Re
|
||||
}
|
||||
|
||||
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
|
||||
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
|
||||
/// layer of what it shows, with the same text choice as Open, and the import notes.
|
||||
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
let opts = ImportOptions {
|
||||
pages: Some(vec![page]),
|
||||
crop: o.crop,
|
||||
password: o.password.clone(),
|
||||
text_as: TextAs::Outlines,
|
||||
text_as: o.text_as,
|
||||
layers: false,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
|
||||
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let o = LoadOptions::from_params(INFO, p)?;
|
||||
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
||||
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return postscript_info(&bytes, p);
|
||||
}
|
||||
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
|
||||
Ok(i) => i,
|
||||
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
|
||||
@@ -179,14 +183,42 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages});
|
||||
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
|
||||
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
|
||||
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
|
||||
if let Some(page) = thumbnail_page(p, info.pages.len(), "PDF")? {
|
||||
let (doc, _) = page_document(&bytes, page, &o)?;
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The page (0-based) `document.pdfInfo`'s `thumbnail` names, from 1 to `count`; `None` when it is
|
||||
/// absent or null. Any other value (0, a negative or fractional number, a string) is an error that
|
||||
/// names the pages the `what` has.
|
||||
fn thumbnail_page(p: &Value, count: usize, what: &str) -> Result<Option<usize>> {
|
||||
let Some(v) = p.get("thumbnail").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let page = v.as_u64().and_then(|n| usize::try_from(n).ok()).and_then(|n| n.checked_sub(1)).filter(|i| *i < count);
|
||||
page.map(Some).ok_or_else(|| bad(INFO, format!("thumbnail: no page {v} (the {what} has {count})")))
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
|
||||
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
|
||||
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
|
||||
/// file's preview when its PostScript can't be read.
|
||||
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
||||
let page = vectorcraft_eps::page(bytes).map_err(err)?;
|
||||
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
|
||||
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
|
||||
if thumbnail_page(p, 1, "file")?.is_some() {
|
||||
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
||||
/// side, base64.
|
||||
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
Ok(vectorcraft_format::base64_encode(&png))
|
||||
}
|
||||
@@ -220,6 +220,21 @@ mod tests {
|
||||
assert!(close(resolution(&jpg).unwrap(), (254.0, 240.0 * 2.54)));
|
||||
}
|
||||
|
||||
/// A CMYK JPEG carries no JFIF (it describes one- and three-component images), so its
|
||||
/// resolution is written as EXIF instead and this reader still finds it (#3084).
|
||||
#[test]
|
||||
fn a_cmyk_jpeg_keeps_its_resolution_without_jfif() {
|
||||
use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions};
|
||||
let o = JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() };
|
||||
let jpg = jpeg::encode(&[0; 4 * 4], 2, 2, 90, Some(240.0), &o).unwrap();
|
||||
assert!(!jpg.windows(5).any(|w| w == b"JFIF\0"), "no JFIF on a CMYK file");
|
||||
assert_eq!(resolution(&jpg), Some((240.0, 240.0)));
|
||||
// An RGB file still says it in JFIF, as it always did.
|
||||
let rgb = jpeg::encode(&[0; 3 * 4], 2, 2, 90, Some(240.0), &JpegOptions::default()).unwrap();
|
||||
assert!(rgb.windows(5).any(|w| w == b"JFIF\0"));
|
||||
assert_eq!(resolution(&rgb), Some((240.0, 240.0)));
|
||||
}
|
||||
|
||||
/// A little-endian TIFF/EXIF block declaring `x`/`y` resolution in `unit`.
|
||||
fn exif_block(x: u32, y: u32, unit: u16) -> Vec<u8> {
|
||||
let mut b = b"II*\0".to_vec();
|
||||
|
||||
@@ -549,7 +549,10 @@ impl SaveJob {
|
||||
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
enc.warnings.splice(0..0, fidelity_warning(self.plan.format));
|
||||
// Lost artboards first: the status bar shows only the first warning.
|
||||
let f = self.plan.format;
|
||||
let lost = if is_svg(f) { super::svg::lost_artboards_warning(&self.doc, &self.params, f.label) } else { None };
|
||||
enc.warnings.splice(0..0, lost.into_iter().chain(fidelity_warning(f)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ impl<'a> Naming<'a> {
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
|
||||
@@ -120,7 +120,9 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
|
||||
match o.get("svg") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Object(svg)) => m.extend(svg.clone()),
|
||||
Some(_) => return Err("`svg` must be an object of SVG options (see document.formats)".into()),
|
||||
Some(_) => {
|
||||
return Err("`svg` must be an object of SVG options (see document.formats)".into());
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
@@ -147,6 +149,17 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>,
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// What a save of `doc` as SVG (format `label`) with options `p` loses of several artboards: the
|
||||
/// file is one artboard and reopens as one, unless it carries the native document (Preserve
|
||||
/// Editing Capabilities).
|
||||
pub(super) fn lost_artboards_warning(doc: &Document, p: &Value, label: &str) -> Option<String> {
|
||||
let n = doc.artboards.len();
|
||||
let preserved = plan(doc, p).is_ok_and(|(opts, ..)| opts.preserve_editing);
|
||||
(n > 1 && !preserved).then(|| {
|
||||
format!("{label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all.")
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
|
||||
/// link to and the writer's warnings.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
|
||||
|
||||
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
|
||||
/// pixels would fit, is refused as too large to draw before it is decoded.
|
||||
#[test]
|
||||
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
|
||||
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
|
||||
let side = 20_000u32;
|
||||
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
|
||||
for at in [18, 30, 102] {
|
||||
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
|
||||
}
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
|
||||
@@ -103,6 +103,87 @@ fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
|
||||
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
|
||||
}
|
||||
|
||||
/// A page of a square and a line of type, as a `.ai`'s PDF part draws them.
|
||||
const SQUARE_AND_TYPE: &str = "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET";
|
||||
|
||||
/// `file` loaded with `clipboard.importPdf` (`editingData` as given) and pasted → the command's
|
||||
/// reply and the objects the paste selected, in stacking order.
|
||||
fn paste(s: &mut Session, file: &[u8], editing_data: bool) -> (Value, Vec<Node>) {
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(file), "editingData": editing_data});
|
||||
let r = s.execute("clipboard.importPdf", &p).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
(r, st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id).cloned()).collect())
|
||||
}
|
||||
|
||||
/// Is any of `nodes` (or an object inside one) type?
|
||||
fn has_type(nodes: &[Node]) -> bool {
|
||||
let mut found = false;
|
||||
nodes.iter().for_each(|n| n.walk(&mut |c| found |= matches!(c.kind, NodeKind::Text(_))));
|
||||
found
|
||||
}
|
||||
|
||||
/// A `.ai` copied in a file manager pastes the objects of its editing data, as `document.open`
|
||||
/// reads it: both layers' objects in their stacking order, into the active layer, with their names
|
||||
/// and hidden members, and its type as type. Without `editingData`, as for a PDF, its page pastes:
|
||||
/// the shown layer's art without names.
|
||||
#[test]
|
||||
fn a_copied_ai_file_pastes_from_its_editing_data() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
|
||||
let (r, pasted) = paste(&mut s, &file, true);
|
||||
assert_eq!(r["count"], 7, "{r}");
|
||||
let pasted_names: Vec<String> = pasted.iter().map(|n| n.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(pasted_names, ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
|
||||
let pair = pasted[0].children().unwrap();
|
||||
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
|
||||
assert!(matches!(pasted[5].kind, NodeKind::Layer { .. }) && pasted[5].locked, "the sublayer comes as it is");
|
||||
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
|
||||
let layers = &s.doc().unwrap().doc.layers;
|
||||
assert_eq!(layers.len(), 1, "no layer is added");
|
||||
assert!(pasted.iter().all(|n| layers[0].children().unwrap().iter().any(|c| c.id == n.id)), "into the active layer");
|
||||
// The same editing data in an EPS: the same objects, the hidden layer's objects hidden.
|
||||
let eps = ai::eps(&ai::sample_data(), ai::page_ps());
|
||||
s.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(&eps)})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let shown = |nodes: Vec<&Node>| nodes.iter().map(|n| (n.name.clone().unwrap_or_default(), n.visible)).collect::<Vec<_>>();
|
||||
assert_eq!(shown(st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id)).collect()), shown(pasted.iter().collect()));
|
||||
let (r, page) = paste(&mut s, &file, false);
|
||||
assert!(page.iter().all(|n| n.name.is_none()), "{r}");
|
||||
// A page of text only, or only a placeholder (saved without PDF compatibility): the editing
|
||||
// data's objects all the same.
|
||||
for (page, boxed) in [(TEXT_ART, "/ArtBox [0 0 200 100]"), (PLACEHOLDER, "")] {
|
||||
let (r, pasted) = paste(&mut s, &ai::ai_with(&compressed(&ai::sample_data()), page, boxed), true);
|
||||
assert_eq!(pasted.first().and_then(|n| n.name.as_deref()), Some("Pair"), "{page}: {r}");
|
||||
assert_eq!(r["count"], 7, "{page}: {r}");
|
||||
}
|
||||
// Type: a text object from the editing data, outlines from the page.
|
||||
let data = ai::editing_data(
|
||||
200.0,
|
||||
100.0,
|
||||
&ai::layer("Art", &format!("0 g\n{}f\n/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n", ai::rect(10.0, 10.0, 50.0, 50.0))),
|
||||
);
|
||||
let typed = ai::ai(&compressed(data.as_bytes()), SQUARE_AND_TYPE);
|
||||
// (The page's type comes in as type too now that PDF import keeps it, so only this side is told apart.)
|
||||
let (r, pasted) = paste(&mut s, &typed, true);
|
||||
assert!(has_type(&pasted), "{r}: {pasted:?}");
|
||||
// A `.ai` VectorCraft wrote: the document it carries, its hidden square too.
|
||||
let mut other = Session::new();
|
||||
other.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
for x in [10, 100] {
|
||||
other.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap();
|
||||
}
|
||||
other.execute("object.hide", &json!({})).unwrap();
|
||||
let saved = other.execute("document.export", &json!({"format": "ai"})).unwrap();
|
||||
let saved = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let (r, pasted) = paste(&mut s, &saved, true);
|
||||
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, false], "{r}");
|
||||
let (r, page) = paste(&mut s, &saved, false);
|
||||
assert!(page.iter().all(|n| n.visible), "{r}");
|
||||
}
|
||||
|
||||
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
|
||||
#[test]
|
||||
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
|
||||
@@ -173,6 +254,29 @@ fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
|
||||
}
|
||||
|
||||
/// A copied `.ai` whose editing data can't be read, or isn't there, pastes its page as a PDF
|
||||
/// does: the same objects as without `editingData`.
|
||||
#[test]
|
||||
fn a_copied_ai_file_whose_editing_data_cant_be_read_pastes_its_page() {
|
||||
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
for (what, private) in
|
||||
[("damaged", b"%AI12_CompressedData not zlib".to_vec()), ("unread layers", compressed(symbols.as_bytes())), ("none", vec![])]
|
||||
{
|
||||
let file = ai::ai(&private, SQUARE_AND_TYPE);
|
||||
let (r, pasted) = paste(&mut s, &file, true);
|
||||
let (want, page) = paste(&mut s, &file, false);
|
||||
assert_eq!((&r["count"], &r["warnings"]), (&want["count"], &want["warnings"]), "{what}");
|
||||
let kinds = |nodes: &[Node]| nodes.iter().map(|n| std::mem::discriminant(&n.kind)).collect::<Vec<_>>();
|
||||
assert_eq!(kinds(&pasted), kinds(&page), "{what}");
|
||||
assert!(!pasted.is_empty(), "{what}: {r}");
|
||||
}
|
||||
// Editing data that reads and draws as the page does: its objects, not the page's.
|
||||
let (r, _) = paste(&mut s, &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), true);
|
||||
assert_eq!(r["count"], 7, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
|
||||
@@ -229,6 +333,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
|
||||
for pages in [json!("1"), json!(1), json!("1-")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
|
||||
check_sample(&s);
|
||||
}
|
||||
// Without editing data, the placeholder still isn't opened as the art.
|
||||
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
|
||||
//! others are read by the PostScript interpreter or come in as their preview.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_svgedit::{comparable, rich};
|
||||
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
|
||||
assert_eq!(images(&s), [want.clone(), want]);
|
||||
}
|
||||
|
||||
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
|
||||
/// differencing, in an EPS and in a PostScript `.ai`.
|
||||
#[test]
|
||||
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
|
||||
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
|
||||
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let ps = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
|
||||
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
for name in ["predicted.eps", "predicted.ai"] {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, name, ps.as_bytes()).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut grays = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
|
||||
}
|
||||
});
|
||||
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_eps_file_comes_in_as_its_preview() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
|
||||
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
|
||||
/// order mark: "hash " and the hash it held, padded with spaces to its length.
|
||||
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = b"(\xFE\xFF".to_vec();
|
||||
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
|
||||
let mut s = rich();
|
||||
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
|
||||
let r = open(&mut s, name, &utf16_described(&file));
|
||||
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
|
||||
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
|
||||
let r = open(&mut s, name, &utf16_described(&edited));
|
||||
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_payload_name_is_accepted() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -38,6 +38,64 @@ fn text_opens_as_type_or_outlines() {
|
||||
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
|
||||
}
|
||||
|
||||
fn live_text_pdf() -> Vec<u8> {
|
||||
let page = |text: &str| PdfPage {
|
||||
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
|
||||
..PdfPage::new(200.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td ({text}) Tj ET"))
|
||||
};
|
||||
pdf(&[page("First page"), page("Second page")], None)
|
||||
}
|
||||
|
||||
fn live_texts(s: &Session) -> Vec<String> {
|
||||
let mut texts = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
texts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
|
||||
let bytes = live_text_pdf();
|
||||
let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
|
||||
let path = dir.join("text.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let mut p = source;
|
||||
p["page"] = json!(2);
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!(live_texts(&s), ["Second page"], "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_clipboard_paste_keeps_live_text_on_the_selected_page() {
|
||||
let bytes = live_text_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "page": 2})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(live_texts(&s), ["Second page"], "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_place_honors_explicit_text_outlines() {
|
||||
let bytes = live_text_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r =
|
||||
s.execute("file.place", &json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "textAs": "outlines"})).unwrap();
|
||||
assert!(live_texts(&s).is_empty());
|
||||
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_option_keeps_or_flattens_optional_content() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
|
||||
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
||||
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
|
||||
}
|
||||
// An EPS with a binary header too, and the PDF info of either.
|
||||
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
|
||||
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
|
||||
// PostScript, so its PDF info is an error.
|
||||
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
binary.extend_from_slice(&[0; 28]);
|
||||
binary.extend_from_slice(ps);
|
||||
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
|
||||
assert!(s.active().is_none());
|
||||
}
|
||||
|
||||
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
|
||||
/// with an error that names File › Open and File › Place, and both of those read the file.
|
||||
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
|
||||
/// names File › Open and File › Place, and both of those read the file.
|
||||
#[test]
|
||||
fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
|
||||
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
|
||||
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
|
||||
}
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
|
||||
assert!(!e.contains("can't be opened"), "{e}");
|
||||
for name in ["legacy.ai", "art.eps"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
@@ -186,6 +190,65 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
|
||||
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
|
||||
/// file here opens on that page with `editingData: false`.
|
||||
#[test]
|
||||
fn pdf_info_reads_postscript_as_one_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
|
||||
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
|
||||
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
|
||||
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
|
||||
for (name, bytes, size) in [
|
||||
("art.eps", &eps[..], (300.0, 200.0)),
|
||||
("legacy.ai", &ai, (101.0, 51.0)),
|
||||
("hires.eps", hires, (100.5, 40.25)),
|
||||
("page.ps", letter, (612.0, 792.0)),
|
||||
("bare.ps", bare, (612.0, 792.0)),
|
||||
] {
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 100, "{name}");
|
||||
let mut v = v;
|
||||
v["thumbnail"] = json!("png");
|
||||
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
|
||||
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
|
||||
}
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no page 2"), "{e}");
|
||||
}
|
||||
|
||||
/// `document.pdfInfo`'s `thumbnail` is a page number from 1, for a PDF and for PostScript alike:
|
||||
/// any other value is an error that names the pages, and an absent or null one gives no thumbnail.
|
||||
#[test]
|
||||
fn pdf_info_refuses_a_thumbnail_that_is_not_a_page_number() {
|
||||
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let mut s = Session::new();
|
||||
for (bytes, pages) in [(&three_pages()[..], "(the PDF has 3)"), (ps, "(the file has 1)")] {
|
||||
for n in [json!(-1), json!(0), json!(1.5), json!("1"), json!(true)] {
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": n})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("thumbnail: no page {n} {pages}")), "{n}: {e}");
|
||||
}
|
||||
for none in [json!({"dataBase64": b64(bytes)}), json!({"dataBase64": b64(bytes), "thumbnail": null})] {
|
||||
assert!(s.execute("document.pdfInfo", &none).unwrap().get("thumbnail").is_none(), "{pages}");
|
||||
}
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 64})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 64, "{pages}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page showing only text, with an editor's private data when `private`.
|
||||
fn text_page(private: bool, also: &str) -> PdfPage {
|
||||
PdfPage {
|
||||
|
||||
@@ -145,6 +145,23 @@ fn preserve_editing_reopens_the_native_document() {
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_several_artboards_as_plain_svg_warns_they_are_lost() {
|
||||
let artboards = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("artboards")).count();
|
||||
let mut s = session(3);
|
||||
let r = s.execute("document.save", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(artboards(&r), 1, "{r}");
|
||||
// First: the status bar shows only the first warning.
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("1 of the document's 3 artboards"), "{r}");
|
||||
assert_eq!(artboards(&s.execute("file.saveAs", &json!({"format": "svgz"})).unwrap()), 1);
|
||||
// Preserve Editing keeps them all (they reopen); a single artboard loses none.
|
||||
for p in [json!({"format": "svg", "preserveEditing": true}), json!({"format": "svg", "svg": {"preserveEditing": true}})] {
|
||||
let r = s.execute("document.save", &p).unwrap();
|
||||
assert_eq!(artboards(&r), 0, "{p}: {r}");
|
||||
}
|
||||
assert_eq!(artboards(&session(1).execute("document.save", &json!({"format": "svg"})).unwrap()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_writes_svg_and_remembers_its_options() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
|
||||
//! ones are refused, and the files open again.
|
||||
//! ones are refused, and the files open again; Targa files open and place.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
|
||||
/// active document's first layer.
|
||||
fn stored_pixels(s: &Session) -> image::RgbaImage {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let im = match &doc.layers[0].children().unwrap()[0].kind {
|
||||
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
};
|
||||
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
|
||||
/// image descriptor `descriptor`.
|
||||
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([depth, descriptor]);
|
||||
f
|
||||
}
|
||||
|
||||
/// Targa files open and place at their pixel size with the transparency they store, by any of the
|
||||
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
|
||||
#[test]
|
||||
fn targa_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
|
||||
let mut s = session();
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
|
||||
// bits are flattened on white) and the black rectangle stays black.
|
||||
for depth in [16, 24, 32] {
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
|
||||
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
|
||||
let px = stored_pixels(&s);
|
||||
assert_eq!(px.dimensions(), (60, 40));
|
||||
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
|
||||
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
|
||||
if depth == 24 {
|
||||
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
|
||||
}
|
||||
let black = px.get_pixel(40, 20).0;
|
||||
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
|
||||
// The next export starts from the session's document again.
|
||||
s = session();
|
||||
}
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
|
||||
for name in ["art.vda", "ART.ICB", "art.vst"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!(r["format"], "tga", "{name}: {r}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
|
||||
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
|
||||
// Copied in a file manager, a Targa file pastes as a bitmap.
|
||||
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
|
||||
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
|
||||
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
|
||||
for e in ["tga", "vda", "icb", "vst"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
|
||||
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
|
||||
}
|
||||
|
||||
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
|
||||
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
|
||||
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
|
||||
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
|
||||
/// attribute bit.
|
||||
#[test]
|
||||
fn targa_layouts_and_alpha_read() {
|
||||
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
|
||||
let mut rle = Vec::new();
|
||||
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
|
||||
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
|
||||
|
||||
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
|
||||
// packet of red, blue and a clear pixel for the first row, then a run of green.
|
||||
let mut f = targa_header(10, 3, 2, 16, 0x31);
|
||||
f.push(0x02);
|
||||
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.push(0x82);
|
||||
f.extend(0x83e0u16.to_le_bytes());
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let px = |x, y| img.get_pixel(x, y).0;
|
||||
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
|
||||
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
|
||||
|
||||
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
|
||||
for fourth in [[0, 0], [0, 255]] {
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x20);
|
||||
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
|
||||
}
|
||||
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x28);
|
||||
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert!(img.pixels().all(|p| p.0[3] == 0));
|
||||
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
|
||||
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
|
||||
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
|
||||
let mut f = targa_header(2, 1, 1, 32, 0x28);
|
||||
f.extend([50, 50, 50, 128]);
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = kind;
|
||||
let at = f.len() as u32;
|
||||
f.extend(area);
|
||||
f.extend(at.to_le_bytes());
|
||||
f.extend([0; 4]);
|
||||
f.extend(b"TRUEVISION-XFILE.\0");
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
|
||||
}
|
||||
|
||||
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
|
||||
// raw row of 8-bit indices and as runs.
|
||||
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
|
||||
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
|
||||
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.extend(data);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
|
||||
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
|
||||
}
|
||||
|
||||
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
|
||||
// its header.
|
||||
let huge = targa_header(11, 32768, 32768, 8, 0);
|
||||
let e = raster_image(&huge).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
|
||||
|
||||
// Headers with values the specification doesn't define aren't Targa files.
|
||||
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
|
||||
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
|
||||
}
|
||||
assert!(raster_image(b"nothing an image reader knows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let mut s = Session::new();
|
||||
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
|
||||
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
|
||||
for (f, keys) in [
|
||||
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
|
||||
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
|
||||
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
|
||||
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
|
||||
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
|
||||
//! every read is checked.
|
||||
|
||||
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
|
||||
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
|
||||
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
|
||||
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
|
||||
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
|
||||
pub(super) fn is_targa(bytes: &[u8]) -> bool {
|
||||
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return false;
|
||||
};
|
||||
matches!(map_type, 0 | 1)
|
||||
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
|
||||
&& (map_type == 1 || !matches!(image_type, 1 | 9))
|
||||
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
|
||||
&& u16::from_le_bytes([w0, w1]) > 0
|
||||
&& u16::from_le_bytes([h0, h1]) > 0
|
||||
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
|
||||
&& descriptor & 0xc0 == 0
|
||||
}
|
||||
|
||||
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
|
||||
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
|
||||
/// Attributes Type, when the file has one, says what they hold:
|
||||
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
|
||||
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
|
||||
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
|
||||
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
|
||||
/// the bit;
|
||||
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
|
||||
/// are divided by it.
|
||||
///
|
||||
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
|
||||
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
|
||||
let kind = attributes_type(bytes);
|
||||
if bits == 0 || matches!(kind, Some(1 | 2)) {
|
||||
img.pixels_mut().for_each(|p| p.0[3] = 255);
|
||||
return;
|
||||
}
|
||||
if bits == 1 {
|
||||
one_bit_alpha(bytes, img);
|
||||
}
|
||||
if kind == Some(4) {
|
||||
for p in img.pixels_mut() {
|
||||
let a = u16::from(p.0[3]);
|
||||
for c in &mut p.0[..3] {
|
||||
// A fully transparent pixel keeps its colors.
|
||||
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
|
||||
*c = v.min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
|
||||
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
|
||||
/// gives it.
|
||||
fn attributes_type(bytes: &[u8]) -> Option<u8> {
|
||||
let footer = bytes.len().checked_sub(26)?;
|
||||
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
|
||||
return None;
|
||||
}
|
||||
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
|
||||
// The area follows the header and ends before the footer; its first field is its size.
|
||||
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(at + 494).copied()
|
||||
}
|
||||
|
||||
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
|
||||
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
|
||||
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
|
||||
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
|
||||
/// the data doesn't reach, keep the alpha they have.
|
||||
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
|
||||
let mapped = matches!(image_type, 1 | 9);
|
||||
// The number of bytes each pixel takes.
|
||||
let size = match image_type {
|
||||
2 | 10 if depth == 16 => 2,
|
||||
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
|
||||
_ => return,
|
||||
};
|
||||
if img.dimensions() != (w, h) {
|
||||
return;
|
||||
}
|
||||
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
|
||||
let map_at = 18 + usize::from(id_len);
|
||||
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
|
||||
// The attribute bit is the top bit of the little-endian pixel or color map entry.
|
||||
let attribute = |at: usize| -> Option<bool> {
|
||||
let px = bytes.get(at..at.checked_add(size)?)?;
|
||||
let high = if mapped {
|
||||
let index = match *px {
|
||||
[i] => u16::from(i),
|
||||
[lo, hi] => u16::from_le_bytes([lo, hi]),
|
||||
_ => return None,
|
||||
};
|
||||
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
|
||||
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
|
||||
} else {
|
||||
*px.get(1)?
|
||||
};
|
||||
Some(high & 0x80 != 0)
|
||||
};
|
||||
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
|
||||
let total = u64::from(w) * u64::from(h);
|
||||
let mut set = |i: u64, opaque: bool| {
|
||||
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
|
||||
let y = if top_down { row } else { h - 1 - row };
|
||||
let x = if right_to_left { w - 1 - col } else { col };
|
||||
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
|
||||
};
|
||||
let mut at = map_at + map_len;
|
||||
let mut i = 0;
|
||||
while i < total {
|
||||
if image_type < 9 {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
continue;
|
||||
}
|
||||
let Some(&packet) = bytes.get(at) else { return };
|
||||
let count = u64::from(packet & 0x7f) + 1;
|
||||
at += 1;
|
||||
if packet & 0x80 != 0 {
|
||||
// A run packet repeats one pixel.
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
at += size;
|
||||
for _ in 0..count.min(total - i) {
|
||||
set(i, opaque);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
for _ in 0..count.min(total - i) {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index, name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
"{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
@@ -834,59 +834,94 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// One artboard's part of `artboard.setProps`: its index, new rectangle and name, how far it moved,
|
||||
/// whether it was resized, the map its art and guides take when they go with it (`fit` when it
|
||||
/// scales them), and that art.
|
||||
struct ArtboardEdit<'a> {
|
||||
index: usize,
|
||||
now: Rect,
|
||||
name: Option<&'a str>,
|
||||
delta: vectorcraft_geom::Vec2,
|
||||
resized: bool,
|
||||
fit: Option<Affine>,
|
||||
transform: Option<Affine>,
|
||||
art: Vec<NodeId>,
|
||||
}
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
|
||||
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(p, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
// Scale Artwork with Artboard: resized, it takes the art fully inside it (as Move Artwork with
|
||||
// Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||
let scale_art = bool_or(p, "scaleArt", false);
|
||||
let fit = if scale_art && resized { rect_map(was, now) } else { None };
|
||||
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
|
||||
// leaves the art alone unless scaling was requested. Fit-to-art callers keep the default.
|
||||
let delta = now.origin() - was.origin();
|
||||
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
|
||||
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
|
||||
let art = if transform.is_some() {
|
||||
// Locked and hidden art too? Noted in the journal, so a replay transforms the same objects
|
||||
// whatever the preference is then.
|
||||
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
|
||||
s.note_journal("lockedAndHidden", json!(all));
|
||||
s.doc()?.doc.art_on_artboard(was, all)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut sc = match fit {
|
||||
Some(xf) if Scaling::factor(xf).is_some() => super::object::scaling(s, p),
|
||||
_ => Scaling::default(),
|
||||
};
|
||||
sc.patterns = super::object::transform_patterns(s, p, &art)?;
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = now;
|
||||
if let Some(n) = str_param(p, "name").map(str::trim) {
|
||||
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||||
a.name = if n.is_empty() { format!("Artboard {}", i + 1) } else { n.to_string() };
|
||||
// One artboard (`index` and its fields), or several (`artboards`, each with its own fields).
|
||||
let entries: Vec<&Value> = match p.get("artboards") {
|
||||
Some(list) => {
|
||||
list.as_array().filter(|l| !l.is_empty()).ok_or_else(|| bad("artboard.setProps", "artboards must be a non-empty list"))?.iter().collect()
|
||||
}
|
||||
let id = a.id;
|
||||
if let Some(xf) = transform {
|
||||
d.map_artboard_guides(id, xf);
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(xf, sc);
|
||||
None => vec![p],
|
||||
};
|
||||
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
|
||||
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||
let scale_art = bool_or(p, "scaleArt", false);
|
||||
// Locked and hidden art too?
|
||||
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
|
||||
let d = &s.doc()?.doc;
|
||||
// Each artboard's art is the art inside it before any of them changes; an object inside
|
||||
// several of them goes with the first one listed that moves or scales its art.
|
||||
let mut taken = HashSet::new();
|
||||
let mut edits: Vec<ArtboardEdit> = Vec::with_capacity(entries.len());
|
||||
for q in entries {
|
||||
let index = super::panelcmds::artboard_index(d, q, "artboard.setProps")?;
|
||||
if edits.iter().any(|e| e.index == index) {
|
||||
return Err(bad("artboard.setProps", "an artboard is listed twice"));
|
||||
}
|
||||
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(q, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
let fit = if scale_art && resized { rect_map(was, now) } else { None };
|
||||
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
|
||||
// leaves the art alone unless scaling was requested. Fit-to-art callers keep the default.
|
||||
let delta = now.origin() - was.origin();
|
||||
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
|
||||
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
|
||||
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
|
||||
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art });
|
||||
}
|
||||
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
|
||||
// the preference is then.
|
||||
if edits.iter().any(|e| e.transform.is_some()) {
|
||||
s.note_journal("lockedAndHidden", json!(all));
|
||||
}
|
||||
let scaling =
|
||||
if edits.iter().any(|e| e.fit.is_some_and(|xf| Scaling::factor(xf).is_some())) { super::object::scaling(s, p) } else { Scaling::default() };
|
||||
let art: Vec<NodeId> = edits.iter().flat_map(|e| e.art.iter().copied()).collect();
|
||||
let patterns = super::object::transform_patterns(s, p, &art)?;
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
for e in &edits {
|
||||
let a = d.artboards.get_mut(e.index).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = e.now;
|
||||
if let Some(n) = e.name {
|
||||
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||||
a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() };
|
||||
}
|
||||
let id = a.id;
|
||||
if let Some(xf) = e.transform {
|
||||
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
|
||||
sc.patterns = patterns;
|
||||
d.map_artboard_guides(id, xf);
|
||||
for id in &e.art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
} else if !e.resized {
|
||||
// Moved (not resized), it takes its guides along.
|
||||
d.move_artboard_guides(id, e.delta);
|
||||
}
|
||||
} else if !resized {
|
||||
// Moved (not resized), it takes its guides along.
|
||||
d.move_artboard_guides(id, delta);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if !scale_art {
|
||||
return ok();
|
||||
}
|
||||
Ok(json!({ "scaled": if fit.is_some() { art.iter().map(|i| i.0).collect::<Vec<_>>() } else { vec![] } }))
|
||||
let scaled: Vec<_> = edits.iter().filter(|e| e.fit.is_some()).flat_map(|e| e.art.iter().map(|i| i.0)).collect();
|
||||
Ok(json!({ "scaled": scaled }))
|
||||
}
|
||||
|
||||
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
|
||||
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
|
||||
//! merged into the document's). Items can be put in user-named groups, and a library can be
|
||||
//! exported to a `.vclibrary` file and imported from one (#926).
|
||||
//! exported to a `.vclibrary` file and imported from one (#926). Items and groups are kept in the
|
||||
//! order the Libraries panel shows them, which dragging them there changes.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -281,6 +282,25 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move item `r` next to `to`, an item of its kind: just before it, or just after it when
|
||||
/// `after`, in their kind's list (the order the panel shows them in).
|
||||
fn place_next_to(&mut self, r: &ItemRef, to: &ItemRef, after: bool) {
|
||||
fn shift<T>(list: &mut Vec<T>, key: impl Fn(&T) -> &str, from: &str, to: &str, after: bool) {
|
||||
let Some(i) = list.iter().position(|x| key(x) == from) else { return };
|
||||
let item = list.remove(i);
|
||||
let j = list.iter().position(|x| key(x) == to).map_or(list.len(), |j| j + usize::from(after));
|
||||
list.insert(j.min(list.len()), item);
|
||||
}
|
||||
let (from, to_key) = (r.item.as_str(), to.item.as_str());
|
||||
match r.kind {
|
||||
ItemKind::Color => shift(&mut self.colors, |c| c.name.as_str(), from, to_key, after),
|
||||
ItemKind::CharStyle => shift(&mut self.char_styles, |c| c.name.as_str(), from, to_key, after),
|
||||
ItemKind::ParaStyle => shift(&mut self.para_styles, |c| c.name.as_str(), from, to_key, after),
|
||||
ItemKind::Graphic => shift(&mut self.graphics, |g| g.id.as_str(), from, to_key, after),
|
||||
ItemKind::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
|
||||
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
|
||||
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
|
||||
@@ -552,10 +572,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Library Item to Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group)} put an item in a group (out of the one it was in) → {library, kind, item, group: name or null}",
|
||||
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group), before?: item | after?: item (an item of the same kind, named as `item` is)} put an item in a group (out of the one it was in); with `before` or `after`, also move it just before or after that item in the order its kind is shown in, as dragging it in the Libraries panel does → {library, kind, item, group: name or null}",
|
||||
always,
|
||||
move_item
|
||||
),
|
||||
cmd!(
|
||||
"library.moveGroup",
|
||||
"Move Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, group: name, to: index} move a group to position `to` of the library's groups (0 = first; past the end: last), as dragging its header in the Libraries panel does → {library, group, to: where it went}",
|
||||
always,
|
||||
move_group
|
||||
),
|
||||
cmd!(
|
||||
query "library.export",
|
||||
"Export Library",
|
||||
@@ -1023,13 +1052,30 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.moveItem";
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let group = str_param(p, "group");
|
||||
let next_to = match (str_param(p, "before"), str_param(p, "after")) {
|
||||
(Some(_), Some(_)) => return Err(bad(C, "give `before` or `after`, not both")),
|
||||
(Some(b), None) => Some((b, false)),
|
||||
(None, Some(a)) => Some((a, true)),
|
||||
(None, None) => None,
|
||||
};
|
||||
let (key, to) = s.libraries.change(&id, |lib| {
|
||||
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
|
||||
let missing = |item: &str| bad(C, format!("the library has no item `{item}`"));
|
||||
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| missing(item))?;
|
||||
let at = group.map(|g| group_at(lib, g, C)).transpose()?;
|
||||
let next_to = next_to
|
||||
.map(|(other, after)| Ok::<_, EngineError>((lib.item_ref(kind.item_kind(), other).ok_or_else(|| missing(other))?, after)))
|
||||
.transpose()?;
|
||||
if let Some((other, after)) = &next_to
|
||||
&& *other != r
|
||||
{
|
||||
lib.place_next_to(&r, other, *after);
|
||||
}
|
||||
lib.ungroup(&r);
|
||||
let key = r.item.clone();
|
||||
let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| {
|
||||
g.items.push(r);
|
||||
// Next to the other item when that is in the group too, else last.
|
||||
let near = next_to.as_ref().and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after)));
|
||||
g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r);
|
||||
g.name.clone()
|
||||
});
|
||||
Ok((key, to))
|
||||
@@ -1037,6 +1083,22 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
|
||||
}
|
||||
|
||||
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.moveGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let group = group_param(p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?;
|
||||
let (name, at) = s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
let g = lib.groups.remove(i);
|
||||
let at = usize::try_from(to).unwrap_or(usize::MAX).min(lib.groups.len());
|
||||
let name = g.name.clone();
|
||||
lib.groups.insert(at, g);
|
||||
Ok((name, at))
|
||||
})?;
|
||||
Ok(json!({ "library": id, "group": name, "to": at }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.export";
|
||||
let id = library_id(s, p, C)?;
|
||||
|
||||
@@ -341,6 +341,7 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result<Value>
|
||||
/// repeats; a dialog's preview records it as it commits (`Session::commit_interaction`).
|
||||
fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?;
|
||||
// Bounded so coordinates stay finite (and the document stays serializable).
|
||||
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
@@ -480,6 +481,7 @@ impl Rasterized {
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
check_numbers(p, &["ppi"], cmd)?;
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
return Err(bad(cmd, "ppi must be between 1 and 2400"));
|
||||
@@ -600,6 +602,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
check_numbers(p, &["width", "height", "spacingX", "spacingY"], C)?;
|
||||
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
|
||||
let resize = Affine::translate(b.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
|
||||
@@ -671,8 +674,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return trim_image(s, id, (**im).clone());
|
||||
}
|
||||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let rect = match p.get("rect").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) if a.len() == 4 => {
|
||||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
if v.len() != 4 {
|
||||
return Err(bad(C, "rect must be [x, y, width, height]"));
|
||||
@@ -832,6 +835,7 @@ impl Session {
|
||||
|
||||
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.createTrimMarks";
|
||||
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||||
None => s.crop_mark_style(),
|
||||
@@ -1001,6 +1005,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
L::Row => (1, n_ab),
|
||||
L::Column => (n_ab, 1),
|
||||
};
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
|
||||
@@ -47,6 +47,7 @@ pub(crate) mod menucmds;
|
||||
pub(crate) mod newart;
|
||||
pub mod newdoc;
|
||||
mod object;
|
||||
pub(crate) mod objects_on_path;
|
||||
mod opacitymask;
|
||||
mod overprint;
|
||||
mod package;
|
||||
@@ -257,6 +258,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(brushsym::specs());
|
||||
v.extend(patterncmds::specs());
|
||||
v.extend(intertwine::specs());
|
||||
v.extend(objects_on_path::specs());
|
||||
v.extend(prefscmds::specs());
|
||||
v.extend(distortcmds::specs());
|
||||
v.extend(paint::proxy_specs());
|
||||
@@ -500,7 +502,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Optio
|
||||
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
|
||||
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
|
||||
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
|
||||
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time);
|
||||
s.note_journal(key, serde_json::json!(t));
|
||||
t
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
|s, _| clip_edit(s, true)
|
||||
),
|
||||
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id}", has_doc, isolate),
|
||||
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id?} (default: the selection's first object)", has_doc, isolate),
|
||||
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
|
||||
cmd!(
|
||||
"object.setProps",
|
||||
@@ -243,7 +243,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -1251,12 +1251,17 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// Move each object of `moves` by its offset, as undo step `label`; pattern tiles move too when
|
||||
/// [`transform_patterns`] says so for `ids`. When every move is a rounding error (1e-9 pt or less
|
||||
/// on both axes), the document and its undo history stay as they are.
|
||||
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)]) -> Result<Value> {
|
||||
/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position)
|
||||
/// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says
|
||||
/// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document
|
||||
/// and its undo history stay as they are.
|
||||
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result<Value> {
|
||||
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
|
||||
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
|
||||
let guides: Vec<(usize, f64)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect()
|
||||
};
|
||||
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit(label, |d, _| {
|
||||
@@ -1265,15 +1270,42 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &
|
||||
n.transform(Affine::translate(*dv), sc);
|
||||
}
|
||||
}
|
||||
for &(i, pos) in &guides {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
g.pos = pos;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
|
||||
/// selection.
|
||||
/// What `object.align` aligns to: `to`, else the key object (or key guide) when there is one,
|
||||
/// else the selection.
|
||||
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
|
||||
str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key"))
|
||||
str_param(p, "to").or_else(|| s.doc().ok()?.selection.has_key().then_some("key"))
|
||||
}
|
||||
|
||||
/// The key guide `object.align` lines the objects up on (aligning to the key with one set,
|
||||
/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical.
|
||||
fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> {
|
||||
let st = s.doc().ok()?;
|
||||
let g = st.doc.guides.get(st.selection.key_guide?)?;
|
||||
(align_to(s, p) == Some("key")).then_some((g.pos, g.vertical))
|
||||
}
|
||||
|
||||
/// Where Align puts the objects, `r`, and the axes it moves them along (x, y): a key guide gives
|
||||
/// the line across its axis only (a vertical guide fixes x, a horizontal one y), so the other
|
||||
/// axis stays as it is.
|
||||
fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: Option<Rect>) -> Result<(Rect, bool, bool)> {
|
||||
if let Some((pos, vertical)) = key_guide(s, p) {
|
||||
return Ok((Rect::new(pos, pos, pos, pos), vertical, !vertical));
|
||||
}
|
||||
let r = match own {
|
||||
Some(b) if align_to(s, p) != Some("artboard") => b,
|
||||
_ => reference_rect(s, p, ids, preview)?,
|
||||
};
|
||||
Ok((r, true, true))
|
||||
}
|
||||
|
||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
||||
@@ -1328,7 +1360,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
return align_anchors(s, p, &ids, (h, v));
|
||||
}
|
||||
let r = reference_rect(s, p, &ids, preview)?;
|
||||
let (r, along_x, along_y) = align_reference(s, p, &ids, preview, None)?;
|
||||
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
|
||||
let key = key_root(s, &ids);
|
||||
let moves: Vec<(NodeId, Vec2)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -1352,7 +1385,35 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
apply_moves(s, "Align", p, &ids, &moves)
|
||||
let guides = guide_moves(s, p, r, h, v)?;
|
||||
apply_moves(s, "Align", p, &ids, &moves, &guides)
|
||||
}
|
||||
|
||||
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a
|
||||
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal
|
||||
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides
|
||||
/// when aligning to the selection or the artboard.
|
||||
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
|
||||
if align_to(s, p) != Some("key") {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let at = |vertical: bool| match (vertical, h, v) {
|
||||
(true, Some("left"), _) => Some(r.x0),
|
||||
(true, Some("center"), _) => Some(r.center().x),
|
||||
(true, Some("right"), _) => Some(r.x1),
|
||||
(false, _, Some("top")) => Some(r.y0),
|
||||
(false, _, Some("center")) => Some(r.center().y),
|
||||
(false, _, Some("bottom")) => Some(r.y1),
|
||||
_ => None,
|
||||
};
|
||||
Ok(st
|
||||
.selection
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|&&i| Some(i) != st.selection.key_guide)
|
||||
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point
|
||||
@@ -1367,8 +1428,10 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
|
||||
let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else {
|
||||
return ok();
|
||||
};
|
||||
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does.
|
||||
let r = if align_to(s, p) == Some("artboard") { reference_rect(s, p, ids, false)? } else { bounds };
|
||||
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does
|
||||
// (or to a key guide).
|
||||
let (r, along_x, along_y) = align_reference(s, p, ids, false, Some(bounds))?;
|
||||
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
|
||||
let target = |q: Point| {
|
||||
let x = match h {
|
||||
Some("left") => r.x0,
|
||||
@@ -1515,7 +1578,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
})
|
||||
.collect();
|
||||
apply_moves(s, "Distribute", p, &ids, &moves)
|
||||
apply_moves(s, "Distribute", p, &ids, &moves, &[])
|
||||
}
|
||||
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1560,7 +1623,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
|
||||
apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[])
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Object › Objects on Path (#2545): objects attached to a path and spread along it.
|
||||
//!
|
||||
//! `object.objectsOnPath.attach` puts the selected objects and a path in an Objects on Path group:
|
||||
//! a group with a [`ObjectsOnPath`] record ([`vectorcraft_doc::objects_on_path`]), the path at its
|
||||
//! bottom. Called without a path (Object › Objects on Path › Attach) it chooses the Objects on Path
|
||||
//! tool, whose click on a path attaches the selected objects to that path. The objects go along the
|
||||
//! path column by column from left to right, top to bottom in each column, spread evenly from its
|
||||
//! first anchor and turned to follow it. `object.objectsOnPath.options` sets the group's Pivot and
|
||||
//! Rotate, and `object.objectsOnPath.expand` makes it a plain group.
|
||||
//!
|
||||
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
|
||||
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
|
||||
//! objects added to or taken out of the group join the end of the path or leave the record, and
|
||||
//! art that replaces an object at its index (Break Link to Symbol) keeps its place.
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html> (the tool and
|
||||
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
|
||||
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::*;
|
||||
|
||||
/// The id of the Objects on Path tool.
|
||||
pub const TOOL: &str = "objectsOnPath";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"object.objectsOnPath.attach",
|
||||
"Attach",
|
||||
["Object", "Objects on Path"],
|
||||
None,
|
||||
"{path?: id of the path to attach to (without it: choose the Objects on Path tool, whose click on a path attaches the selection to it), ids?: the objects to attach (default: the selection; the path is left out of it), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; default 4, the center), rotate?: degrees counter-clockwise from upright on the path (default 0)} put the objects and the path in an Objects on Path group: the path at the bottom, the objects spread evenly along it from its first anchor (the last one at the end of an open path) in their order on the canvas, column by column from left to right and top to bottom in each column, each turned to follow the path; editing the path moves them → {id}",
|
||||
has_selection,
|
||||
attach
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.options",
|
||||
"Objects on Path Options",
|
||||
[],
|
||||
None,
|
||||
"{ids?: Objects on Path groups or objects in them (default: the selection), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; 4: the center), rotate?: degrees counter-clockwise from upright on the path} set how the objects of the groups attach → {ids}",
|
||||
has_objects_on_path,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.expand",
|
||||
"Expand",
|
||||
["Object", "Objects on Path"],
|
||||
None,
|
||||
"{ids?: Objects on Path groups or objects in them (default: the selection)} make the groups plain groups of the path and the objects where they are → {ids}",
|
||||
has_objects_on_path,
|
||||
expand
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- predicates ----------
|
||||
|
||||
/// The Objects on Path groups of `ids`: the ones among them, and the innermost one around each
|
||||
/// object in one.
|
||||
fn groups_of(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let group = d.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| d.node(*a).is_some_and(is_objects_on_path));
|
||||
if let Some(g) = group
|
||||
&& !out.contains(&g)
|
||||
{
|
||||
out.push(g);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Objects on Path groups of the selection ([`groups_of`]).
|
||||
fn selected_groups(s: &Session) -> Vec<NodeId> {
|
||||
s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The groups a command acts on: those of `ids` when given, otherwise those of the selection.
|
||||
fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_none_or(Value::is_null) {
|
||||
return Ok(selected_groups(s));
|
||||
}
|
||||
let ids = checked_ids_param(s, p, "ids", cmd)?;
|
||||
let groups = groups_of(&s.doc()?.doc, &ids);
|
||||
if groups.is_empty() {
|
||||
return Err(bad(cmd, "`ids` must name Objects on Path groups or objects in them"));
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The Objects on Path group around `id` (not `id` itself), if any.
|
||||
fn group_around(d: &Document, id: NodeId) -> Option<NodeId> {
|
||||
d.ancestry(id).unwrap_or_default().into_iter().filter(|a| *a != id).find(|a| d.node(*a).is_some_and(is_objects_on_path))
|
||||
}
|
||||
|
||||
// ---------- Attach ----------
|
||||
|
||||
/// `pivot` and `rotate` from `p`, each checked.
|
||||
fn pivot_rotate(p: &Value, cmd: &str, pivot: u8, rotate: f64) -> Result<(u8, f64)> {
|
||||
check_numbers(p, &["pivot", "rotate"], cmd)?;
|
||||
let pivot = match p.get("pivot").filter(|v| !v.is_null()) {
|
||||
None => pivot,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 8).map(|n| n as u8).ok_or_else(|| bad(cmd, "`pivot` must be a whole number from 0 to 8"))?,
|
||||
};
|
||||
let rotate = match p.get("rotate").and_then(Value::as_f64) {
|
||||
None => rotate,
|
||||
Some(r) if r.is_finite() => r,
|
||||
Some(_) => return Err(bad(cmd, "`rotate` must be a finite number")),
|
||||
};
|
||||
Ok((pivot, rotate))
|
||||
}
|
||||
|
||||
/// `ids` in their order along the path: column by column from left to right, top to bottom in each
|
||||
/// column. A column is a run of objects, taken by their left edges, that each start left of the
|
||||
/// right edge of the column so far. Objects without bounds go last. Symbol instances are measured
|
||||
/// by their art.
|
||||
fn path_order(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut placed: Vec<(NodeId, Rect)> = vec![];
|
||||
let mut rest = vec![];
|
||||
for id in ids {
|
||||
match d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)) {
|
||||
Some(b) => placed.push((*id, b)),
|
||||
None => rest.push(*id),
|
||||
}
|
||||
}
|
||||
placed.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
|
||||
let mut columns: Vec<(f64, Vec<(NodeId, Rect)>)> = vec![];
|
||||
for (id, b) in placed {
|
||||
match columns.last_mut() {
|
||||
Some((right, column)) if b.x0 < *right => {
|
||||
*right = right.max(b.x1);
|
||||
column.push((id, b));
|
||||
}
|
||||
_ => columns.push((b.x1, vec![(id, b)])),
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for (_, mut column) in columns {
|
||||
column.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
|
||||
out.extend(column.into_iter().map(|(id, _)| id));
|
||||
}
|
||||
out.extend(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Why the path `path` can't take objects, if it can't.
|
||||
fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
|
||||
let n = d.node(path)?;
|
||||
if attachable_path(n).is_none() {
|
||||
return Some("pick a path to attach the objects to (not a guide, a compound path or type)");
|
||||
}
|
||||
if group_around(d, path).is_some() {
|
||||
return Some("the path is already in an Objects on Path group");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
|
||||
fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
|
||||
if n.is_layer() {
|
||||
return Some("layers can't be attached to a path: select the objects in them");
|
||||
}
|
||||
if group_around(d, n.id).is_some() {
|
||||
return Some("an object already on a path can't be attached again");
|
||||
}
|
||||
let mut graph = false;
|
||||
n.walk(&mut |c| graph |= c.extras().graph.is_some());
|
||||
graph.then_some("graphs can't be attached to a path")
|
||||
}
|
||||
|
||||
fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.objectsOnPath.attach";
|
||||
let Some(path) = p.get("path").filter(|v| !v.is_null()) else {
|
||||
let view = s.last_view;
|
||||
s.select_tool(TOOL, view)?;
|
||||
return Ok(json!({ "tool": TOOL }));
|
||||
};
|
||||
let path = checked_id(s, path, C)?;
|
||||
let (pivot, rotate) = pivot_rotate(p, C, CENTER, 0.0)?;
|
||||
let roots = match p.get("ids") {
|
||||
Some(_) => roots_of(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?),
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let ids: Vec<NodeId> = roots.into_iter().filter(|id| *id != path).collect();
|
||||
let order = {
|
||||
let d = &s.doc()?.doc;
|
||||
if let Some(why) = path_refused(d, path) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the objects to attach to the path"));
|
||||
}
|
||||
for id in &ids {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if d.ancestry(path).unwrap_or_default().contains(id) {
|
||||
return Err(bad(C, "the path can't be inside one of the objects to attach"));
|
||||
}
|
||||
if let Some(why) = object_refused(d, n) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
}
|
||||
path_order(d, &ids)
|
||||
};
|
||||
let id = s.edit("Attach Objects on Path", |d, sel| {
|
||||
let members: Vec<Node> = order.iter().filter_map(|id| d.node(*id).cloned()).collect();
|
||||
let rec = ObjectsOnPath::new(path, &members.iter().collect::<Vec<_>>(), pivot, rotate);
|
||||
let mut wrapped = ids.clone();
|
||||
wrapped.push(path);
|
||||
let wrapped = d.paint_order(wrapped);
|
||||
let id = d.alloc_id();
|
||||
d.wrap(&wrapped, |mut children| {
|
||||
// The path at the bottom: the objects are drawn over it.
|
||||
if let Some(i) = children.iter().position(|c| c.id == path) {
|
||||
let p = children.remove(i);
|
||||
children.insert(0, p);
|
||||
}
|
||||
let mut g = Node::new(id, NodeKind::Group { children, clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
g
|
||||
})?;
|
||||
place_in(d, id);
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
// ---------- Options ----------
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.objectsOnPath.options";
|
||||
let groups = target_groups(s, p, C)?;
|
||||
pivot_rotate(p, C, CENTER, 0.0)?;
|
||||
s.edit("Objects on Path Options", |d, _| {
|
||||
for g in &groups {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
let Some(rec) = n.extras().objects_on_path.as_deref().cloned() else { continue };
|
||||
let (pivot, rotate) = pivot_rotate(p, C, rec.pivot, rec.rotate)?;
|
||||
n.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath { pivot, rotate, ..rec }));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups = target_groups(s, p, "object.objectsOnPath.expand")?;
|
||||
s.edit("Expand Objects on Path", |d, sel| {
|
||||
for g in &groups {
|
||||
d.node_mut(*g).ok_or(EngineError::NoNode(*g))?.extras_mut().objects_on_path = None;
|
||||
}
|
||||
sel.set(groups.iter().copied());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- refresh ----------
|
||||
|
||||
/// After an edit: put the objects of every Objects on Path group that changed back on its path
|
||||
/// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
|
||||
let mut groups = vec![];
|
||||
changed_groups(&before.layers, &doc.layers, &mut groups);
|
||||
for id in groups.iter().rev() {
|
||||
follow_replacements(before, doc, *id);
|
||||
place_in(doc, *id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Members of the group `id` that the edit replaced with new art at the same index keep their
|
||||
/// place on the path ([`ObjectsOnPath::follow_replacements`]).
|
||||
fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) {
|
||||
let (Some(old), Some(new)) = (before.node(id), doc.node(id)) else { return };
|
||||
let (Some(old), Some(new)) = (old.children(), new.children()) else { return };
|
||||
let Some(mut rec) = doc.node(id).and_then(|n| n.extras().objects_on_path.as_deref().cloned()) else { return };
|
||||
let unchanged = rec.clone();
|
||||
rec.follow_replacements(old, new);
|
||||
if rec != unchanged
|
||||
&& let Some(n) = doc.node_mut(id)
|
||||
{
|
||||
n.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art.
|
||||
fn place_in(doc: &mut Document, id: NodeId) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let mut g = n.clone();
|
||||
if place(&mut g, &doc.symbol_boxes())
|
||||
&& let Some(slot) = doc.node_mut(id)
|
||||
{
|
||||
*slot = g;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Objects on Path groups among `after` and inside them that are not the same nodes as before
|
||||
/// (`before`: the same list before the edit), outer ones first.
|
||||
fn changed_groups(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
|
||||
let mut by_id: Option<HashMap<NodeId, &Arc<Node>>> = None;
|
||||
for (i, a) in after.iter().enumerate() {
|
||||
let b = match before.get(i) {
|
||||
Some(b) if b.id == a.id => Some(b),
|
||||
_ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(),
|
||||
};
|
||||
if b.is_some_and(|b| Arc::ptr_eq(b, a)) {
|
||||
continue;
|
||||
}
|
||||
if is_objects_on_path(a) {
|
||||
out.push(a.id);
|
||||
}
|
||||
if let Some(children) = a.children() {
|
||||
changed_groups(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul
|
||||
|
||||
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
|
||||
fn dos_time() -> (u16, u16) {
|
||||
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
|
||||
let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) };
|
||||
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
|
||||
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
|
||||
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -37,10 +38,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Direction Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
has_doc,
|
||||
set_handle
|
||||
),
|
||||
cmd!(
|
||||
"path.moveAnchor",
|
||||
"Move Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, anchor, x, y} move one anchor to (x, y), its handles with it",
|
||||
has_doc,
|
||||
move_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.setAnchors",
|
||||
"Set Anchors",
|
||||
@@ -64,7 +74,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; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
|
||||
"{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), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} 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
|
||||
),
|
||||
@@ -100,11 +110,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!(
|
||||
"path.cutAtAnchors",
|
||||
"Cut Path at Selected Anchor Points",
|
||||
@@ -215,7 +225,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
@@ -229,6 +239,22 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
|
||||
/// tool's Shift-drag).
|
||||
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize;
|
||||
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
|
||||
s.edit("Move", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
*a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
|
||||
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
|
||||
@@ -310,10 +336,19 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
|
||||
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
|
||||
}
|
||||
|
||||
/// Corner or smooth on an anchor a join leaves behind.
|
||||
fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) {
|
||||
if smooth {
|
||||
sp.smooth_anchor(ai);
|
||||
} else if let Some(a) = sp.anchors.get_mut(ai) {
|
||||
a.retract();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: Option<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.
|
||||
@@ -326,6 +361,9 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
sp.anchors.pop();
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(sp, 0, smooth);
|
||||
}
|
||||
}
|
||||
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
|
||||
for i in (1..sp.anchors.len()).rev() {
|
||||
@@ -336,6 +374,9 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
|
||||
{
|
||||
a.h_out = b.h_out;
|
||||
sp.anchors.remove(i);
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(sp, i - 1, smooth);
|
||||
}
|
||||
}
|
||||
}
|
||||
before - sp.anchors.len()
|
||||
@@ -343,6 +384,12 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let join_kind = match p.get("to").and_then(Value::as_str) {
|
||||
None => None,
|
||||
Some("smooth") => Some(true),
|
||||
Some("corner") => Some(false),
|
||||
Some(_) => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
|
||||
};
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => {
|
||||
let st = s.doc()?;
|
||||
@@ -360,7 +407,8 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
let n: usize =
|
||||
path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)), join_kind)).sum();
|
||||
if n > 0 {
|
||||
merged.push((*id, path));
|
||||
}
|
||||
@@ -400,6 +448,11 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(first) = sp.anchors.first_mut()
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
// Closed first, so the merged anchor smooths between both its neighbours.
|
||||
sp.closed = true;
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(sp, 0, smooth);
|
||||
}
|
||||
}
|
||||
sp.closed = true;
|
||||
Ok(())
|
||||
@@ -432,6 +485,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ry {
|
||||
y.reverse();
|
||||
}
|
||||
let junction = x.anchors.len() - 1;
|
||||
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
|
||||
&& xe.distance(ys) < 1e-6
|
||||
{
|
||||
@@ -441,6 +495,9 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
x.anchors.extend(y.anchors);
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(&mut x, junction, smooth);
|
||||
}
|
||||
pa.subpaths[sa] = x;
|
||||
d.remove(b)?;
|
||||
sel.set([a]);
|
||||
@@ -601,7 +658,6 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
if gone.is_empty() {
|
||||
return vec![sp];
|
||||
}
|
||||
let keep = |anchors: Vec<Anchor>| (anchors.len() >= 2).then(|| SubPath::new(anchors, false));
|
||||
let mut runs: Vec<Vec<Anchor>> = vec![];
|
||||
let mut run = vec![];
|
||||
for (i, anchor) in sp.anchors.into_iter().enumerate() {
|
||||
@@ -629,7 +685,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
} else if !run.is_empty() {
|
||||
runs.push(run);
|
||||
}
|
||||
runs.into_iter().filter_map(keep).collect()
|
||||
runs.into_iter().filter_map(open_run).collect()
|
||||
}
|
||||
|
||||
/// Remove connections, splitting open/closed contours into their surviving open runs.
|
||||
@@ -680,18 +736,27 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((i, dist)) if dist <= tol => i,
|
||||
_ => sp.insert_anchor(seg, t),
|
||||
};
|
||||
let origin = sp.anchors[grabbed].p;
|
||||
// Each anchor's distance along the path from the grabbed one, the shorter way round a
|
||||
// closed path: the lengths are measured once, as a path can have many anchors.
|
||||
let pos = sp.anchor_arc_positions();
|
||||
let total = pos.last().copied().unwrap_or(0.0);
|
||||
// The default reach is the subpath's size, as before, now measured along it.
|
||||
let r = radius.unwrap_or_else(|| {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&bp);
|
||||
(b.width().hypot(b.height()) / 2.0).max(1.0)
|
||||
});
|
||||
let from = pos.get(grabbed).copied().unwrap_or(0.0);
|
||||
let closed = sp.closed;
|
||||
for (i, a) in sp.anchors.iter_mut().enumerate() {
|
||||
let w = if i == grabbed {
|
||||
1.0
|
||||
} else {
|
||||
let q = (a.p.distance(origin) / r).min(1.0);
|
||||
let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs();
|
||||
let d = if closed { d.min(total - d) } else { d };
|
||||
// A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move.
|
||||
let q = (d / r).min(1.0);
|
||||
(1.0 - q * q).powi(2)
|
||||
};
|
||||
let v = delta * w;
|
||||
@@ -706,7 +771,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
|
||||
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Simplify…",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{tolerance?: pt (1), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts",
|
||||
"{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts, and the level used with level or auto",
|
||||
has_selection,
|
||||
simplify
|
||||
),
|
||||
@@ -800,13 +800,29 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -
|
||||
}
|
||||
|
||||
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// With auto, the slider's level is Auto-Simplify's for the selection's size; otherwise it is the level given.
|
||||
let level = if bool_or(p, "auto", false) {
|
||||
let roots = selected_roots(s)?;
|
||||
let size = s.doc()?.doc.bounds_of(&roots, false).map_or(0.0, |b| b.width().max(b.height()));
|
||||
Some(po::auto_simplify_level(size))
|
||||
} else {
|
||||
num_param(p, "level").map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL })
|
||||
};
|
||||
let tolerance = match level {
|
||||
Some(l) => po::simplify_level_tolerance(l),
|
||||
None => len_param(p, "tolerance").unwrap_or(1.0),
|
||||
};
|
||||
let opts = po::SimplifyOptions {
|
||||
tolerance: len_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
|
||||
tolerance: tolerance.clamp(1e-4, 1e4),
|
||||
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
|
||||
straight_lines: bool_or(p, "straightLines", false),
|
||||
};
|
||||
let (ids, before, after) = edit_paths(s, "Simplify", |path| po::simplify_with(path, &opts))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after });
|
||||
if let Some(l) = level {
|
||||
out["level"] = json!(l);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn add_anchor_points(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
//! preference, and listed with `plugin.list` / `plugin.info`. Object filters run
|
||||
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
|
||||
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
|
||||
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
|
||||
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`; exporters
|
||||
//! add a file format that `document.export` writes (`fileio::plugin_formats`).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry};
|
||||
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -27,8 +28,13 @@ pub fn describe(p: &Plugin) -> Value {
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
});
|
||||
if m.kind == Kind::Effect {
|
||||
v["effect"] = json!(effect::effect_id(&m.id));
|
||||
match m.kind {
|
||||
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
|
||||
Kind::Exporter => {
|
||||
v["format"] = json!(export::format_id(&m.id));
|
||||
v["extensions"] = json!(m.extensions);
|
||||
}
|
||||
Kind::Filter => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -56,8 +62,14 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.run";
|
||||
let plugin = plugin_by_id(C, p)?;
|
||||
let m = plugin.manifest();
|
||||
if m.kind != Kind::Filter {
|
||||
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
|
||||
match m.kind {
|
||||
Kind::Filter => {}
|
||||
Kind::Effect => {
|
||||
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
|
||||
}
|
||||
Kind::Exporter => {
|
||||
return Err(bad(C, format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id))));
|
||||
}
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
@@ -206,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cancel Key Object",
|
||||
[],
|
||||
None,
|
||||
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: no id)} the key object Align aligns to and Distribute Spacing spaces from; without `id` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members); any other id is an error that leaves the key as it was → {key} (the resulting key object, or null)",
|
||||
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection; any other id or guide is an error that leaves the key as it was → {key, guide?} (the resulting key object, or null; guide: the key guide, left out without one)",
|
||||
has_doc,
|
||||
key
|
||||
),
|
||||
@@ -89,6 +89,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
anchors_many
|
||||
),
|
||||
cmd!(
|
||||
"select.color",
|
||||
"Select by Color",
|
||||
[],
|
||||
None,
|
||||
"{fill?: colour, stroke?: colour (as paint.setFill's color; at least one)} select the objects whose fill (and stroke) reads as that colour in #rrggbb, as document.inspect reports colours, whatever its swatch or tint; with both, the objects that match both. No object needs to be selected first; Select > Reselect repeats it → {count}",
|
||||
has_doc,
|
||||
select_color
|
||||
),
|
||||
cmd!(
|
||||
"select.same.fillColor",
|
||||
"Fill Color",
|
||||
@@ -359,6 +368,29 @@ fn selection_result(s: &Session) -> Result<Value> {
|
||||
|
||||
fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?;
|
||||
let guide = match p.get("guide").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("select.key", "`guide` must be a guide index (guide.list)"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if let Some(g) = guide {
|
||||
let sel = &s.doc()?.selection;
|
||||
if id.is_some() {
|
||||
return Err(bad("select.key", "give `id` or `guide`, not both"));
|
||||
}
|
||||
if !sel.guides.contains(&g) {
|
||||
return Err(bad("select.key", format!("guide {g} is not selected")));
|
||||
}
|
||||
if sel.is_empty() {
|
||||
return Err(bad("select.key", "select objects to align to the guide"));
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.key = None;
|
||||
sel.key_guide = Some(g);
|
||||
})?;
|
||||
return key_result(s);
|
||||
}
|
||||
if let Some(id) = id {
|
||||
let st = s.doc()?;
|
||||
// A selected top-level object is the only top-level object among the selected ones of
|
||||
@@ -368,8 +400,21 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("select.key", format!("object {id} is not a selected top-level object")));
|
||||
}
|
||||
}
|
||||
s.select(|_, sel| sel.key = id)?;
|
||||
Ok(json!({ "key": s.doc()?.selection.key }))
|
||||
s.select(|_, sel| {
|
||||
sel.key = id;
|
||||
sel.key_guide = None;
|
||||
})?;
|
||||
key_result(s)
|
||||
}
|
||||
|
||||
/// `{key}`, with `guide` when a guide is the key.
|
||||
fn key_result(s: &Session) -> Result<Value> {
|
||||
let sel = &s.doc()?.selection;
|
||||
let mut out = json!({ "key": sel.key });
|
||||
if let Some(g) = sel.key_guide {
|
||||
out["guide"] = json!(g);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being
|
||||
@@ -528,6 +573,29 @@ fn stroke_weight(n: &Node) -> f64 {
|
||||
if paint.is_none() { 0.0 } else { width }
|
||||
}
|
||||
|
||||
/// Select by Color: the objects whose fill and stroke read as the colours given, compared as the
|
||||
/// `#rrggbb` `document.inspect` reports, so a script can pick art by colour without an object id.
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.color";
|
||||
let hex = |key: &str| match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => color_value(v).map(|c| Some(c.to_hex())).ok_or_else(|| bad(C, format!("`{key}`: not a colour: {v}"))),
|
||||
};
|
||||
let (fill, stroke) = (hex("fill")?, hex("stroke")?);
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return Err(bad(C, "give `fill`, `stroke` or both"));
|
||||
}
|
||||
let reads_as = |paint: Paint, hex: Option<&str>| hex.is_none_or(|h| paint.color().is_some_and(|c| c.to_hex() == h));
|
||||
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
|
||||
}
|
||||
|
||||
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
|
||||
/// it holds its members, and its members report none.
|
||||
fn paint_reported(d: &Document, n: &Node) -> bool {
|
||||
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
|
||||
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
|
||||
@@ -272,7 +272,14 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.map(|e| (e.path.clone(), e.lib.clone()))
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => (file_name(&p), read_file(&p)?),
|
||||
Some(p) => {
|
||||
let ext = extension(&p);
|
||||
let base = file_name(&p);
|
||||
// Color books (.acb) name the tab from the book title inside the file; a sandbox
|
||||
// copy of an import often lands as tmp….tmp.acb (#2996).
|
||||
let file = if ext == "acb" || temporary_library_file_name(&base) { library_file_name(lib.name(), &ext) } else { base };
|
||||
(file, read_file(&p)?)
|
||||
}
|
||||
None => (library_file_name(lib.name(), ext), encode(&lib)?),
|
||||
};
|
||||
self.rescan();
|
||||
@@ -302,6 +309,12 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A library file name that looks like a host temporary (e.g. macOS `tmp1F2C.tmp.acb`).
|
||||
fn temporary_library_file_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.contains(".tmp.")
|
||||
}
|
||||
|
||||
/// The file name of library `name` with extension `ext`: characters file systems reject become
|
||||
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`.
|
||||
pub fn library_file_name(name: &str, ext: &str) -> String {
|
||||
|
||||
@@ -459,7 +459,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped, mut typed) = s.edit("Apply Data Set", |d, sel| {
|
||||
let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
// text variables on the same object land the later one's value and both are counted.
|
||||
@@ -467,6 +467,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
for v in &d.variables.variables {
|
||||
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
|
||||
}
|
||||
// Whether an object is bound to a variable that this row has a value for (the reply's `warning`).
|
||||
let bound = pairs.iter().any(|(_, var)| ds.values.contains_key(var));
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
|
||||
@@ -514,12 +516,18 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
d.variables.active_dataset = Some(name.to_string());
|
||||
// Every selected object left hidden or locked, such as one the row hid, is deselected.
|
||||
sel.deselect_uneditable(d);
|
||||
Ok((applied, skipped, typed))
|
||||
Ok((applied, skipped, typed, bound))
|
||||
})?;
|
||||
// Two text variables on one object rewrite it twice; it is named once.
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
Ok(super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped})))
|
||||
let mut out = super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped}));
|
||||
// A row with no value for any bound variable applies nothing. The reply then carries a
|
||||
// `warning`, which the app shows in its status bar.
|
||||
if !bound {
|
||||
out["warning"] = json!(format!("nothing in data set {name} was applied: no object is bound to a variable it has a value for"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1037,7 +1045,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits)}",
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits), warning? (when no object is bound to a variable that the dataset has a value for, so nothing was applied)}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
|
||||
@@ -87,10 +87,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(s.menu.wand))
|
||||
}
|
||||
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all).
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all; type
|
||||
/// without any, the default character fill, as `document.inspect` reports it).
|
||||
pub(crate) fn fill_of(n: &Node) -> Paint {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_else(|| vectorcraft_doc::CharStyle::default().fill),
|
||||
_ => n.appearance.fill_paint(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,8 @@ fn document_base(s: &Session) -> Value {
|
||||
"target": st.selection.target.map(|t| t.0),
|
||||
// The key object Align aligns to and Distribute Spacing spaces from (`select.key`).
|
||||
"key": st.selection.key.map(|k| k.0),
|
||||
// The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`.
|
||||
"keyGuide": st.selection.key_guide,
|
||||
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"objects": d.node_count(),
|
||||
|
||||
@@ -36,6 +36,7 @@ pub use journal::Journal;
|
||||
pub use tooling::{UiRequest, ViewInfo};
|
||||
pub use vectorcraft_doc as doc;
|
||||
pub use vectorcraft_doc::cancel;
|
||||
pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, simplify_level_tolerance};
|
||||
pub use vectorcraft_tools as tools;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -773,6 +774,11 @@ pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool {
|
||||
sel.remove(id);
|
||||
}
|
||||
sel.guides.clear();
|
||||
sel.key_guide = None;
|
||||
// A key object kept only for the guides to line up on goes with them.
|
||||
if sel.key.is_some_and(|k| sel.objects.iter().all(|o| *o == k)) {
|
||||
sel.key = None;
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
@@ -1348,6 +1354,8 @@ impl Session {
|
||||
// Placed documents are read by the native format's loader.
|
||||
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
|
||||
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
|
||||
// Clicks find an image's own fills and strokes where their effects draw them.
|
||||
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
|
||||
Self {
|
||||
docs: vec![],
|
||||
active: None,
|
||||
@@ -1702,14 +1710,17 @@ impl Session {
|
||||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
// Dimensions follow the art they measure.
|
||||
// Objects attached to a path follow it, and dimensions follow the art they measure.
|
||||
let result = match f(doc, &mut st.selection).and_then(|v| {
|
||||
cmd::objects_on_path::refresh(&before, doc);
|
||||
cmd::dimension::refresh(&before, doc)?;
|
||||
Ok(v)
|
||||
}) {
|
||||
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
|
||||
Err(e) => Err(e),
|
||||
Ok(()) => {
|
||||
// Attached Shaper groups whose art was rebuilt go back on their path.
|
||||
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Inline graphics in text follow their symbols.
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
@@ -2143,6 +2154,8 @@ mod tests_intertwine;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_keyguide;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
#[cfg(test)]
|
||||
mod tests_labspots;
|
||||
@@ -2185,6 +2198,8 @@ mod tests_newart;
|
||||
#[cfg(test)]
|
||||
mod tests_newdoc;
|
||||
#[cfg(test)]
|
||||
mod tests_objects_on_path;
|
||||
#[cfg(test)]
|
||||
mod tests_objexpand;
|
||||
#[cfg(test)]
|
||||
mod tests_opacitymask;
|
||||
|
||||
@@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
|
||||
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_falloff_follows_arc_length_on_open_paths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute(
|
||||
"path.create",
|
||||
&json!({
|
||||
"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}],
|
||||
"closed": false
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
|
||||
let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}");
|
||||
assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}");
|
||||
assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}");
|
||||
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
|
||||
let mut s = session();
|
||||
@@ -2429,6 +2452,29 @@ fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
/// A new width or height, or a spacing, given as text or as another value that is not a number is an error naming the
|
||||
/// key, and no mosaic is made.
|
||||
#[test]
|
||||
fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
for (p, msg) in [
|
||||
(json!({"width": "200"}), "`width` must be a number, not \"200\""),
|
||||
(json!({"columns": 2, "height": "50 pt"}), "`height` must be a number, not \"50 pt\""),
|
||||
(json!({"spacingX": "4"}), "`spacingX` must be a number, not \"4\""),
|
||||
(json!({"spacingY": [4]}), "`spacingY` must be a number, not [4]"),
|
||||
] {
|
||||
let r = s.execute("object.createObjectMosaic", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
|
||||
assert_eq!(undo(&s), before, "{p}");
|
||||
}
|
||||
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "width": 200, "spacingX": null})).unwrap();
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_pixel_rounds_drawing_and_moves() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use crate::cmd::clipboard::{AI, BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -241,7 +241,8 @@ fn copied_ai_files_paste_as_the_pdf_or_postscript_inside() {
|
||||
let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 40 40\n%%EndComments\n1 0 0 setrgbcolor\nnewpath 0 0 moveto 40 0 lineto 40 40 lineto closepath fill\nshowpage\n";
|
||||
let of = |name: &str, bytes: &[u8]| file_flavour(name, &bytes[..bytes.len().min(FILE_HEAD)], &PASTE_ORDER);
|
||||
for name in ["art.ai", "art.ait", "ART.AI"] {
|
||||
assert_eq!(of(name, &pdf), Some(PDF), "{name}");
|
||||
// Asked for as PDF; Paste reads it from its editing data (tests_aiimport).
|
||||
assert_eq!(of(name, &pdf), Some(AI), "{name}");
|
||||
assert_eq!(of(name, ps), Some(EPS), "{name}");
|
||||
}
|
||||
// Neither PDF nor PostScript: nothing to paste, whatever the name says.
|
||||
@@ -315,6 +316,53 @@ fn copy_offers_flavours_by_the_preferences() {
|
||||
assert_eq!((f[0].data.as_slice(), f[1].mime), (b"Words".as_slice(), SVG));
|
||||
}
|
||||
|
||||
/// A PNG of `side` × `side` noise pixels: noise keeps it about `side² × 3` bytes, as a photo.
|
||||
fn noise_png(side: u32) -> Vec<u8> {
|
||||
let mut x: u32 = 0x9e37_79b9;
|
||||
let px = (0..side * side * 3)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
(x >> 24) as u8
|
||||
})
|
||||
.collect();
|
||||
let mut out = vec![];
|
||||
image::RgbImage::from_raw(side, side, px).unwrap().write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// #2213: a placed photo in the plain-text flavour made every paste read megabytes of SVG back
|
||||
/// through the system clipboard. Past 1 MiB of embedded images the SVG goes out under its own type
|
||||
/// and the PNG only; a small picture, vector art of any size and type keep their text.
|
||||
#[test]
|
||||
fn a_copy_with_a_large_image_offers_no_svg_text() {
|
||||
let mut s = session();
|
||||
let mimes = |s: &Session| s.clipboard_flavours().iter().map(|f| f.mime).collect::<Vec<_>>();
|
||||
for (side, text) in [(700, false), (64, true)] {
|
||||
let png = noise_png(side);
|
||||
assert_eq!(png.len() > 1 << 20, !text, "{} bytes", png.len());
|
||||
run(&mut s, "clipboard.importImage", json!({"dataBase64": vectorcraft_format::base64_encode(&png)}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let want: &[&str] = if text { &[TEXT, SVG, PNG] } else { &[SVG, PNG] };
|
||||
assert_eq!(mimes(&s), want, "{side} px");
|
||||
assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!(want), "{side} px");
|
||||
// The SVG still embeds the picture, for apps that read it.
|
||||
let svg = s.clipboard_flavours().into_iter().find(|f| f.mime == SVG).unwrap();
|
||||
assert!(String::from_utf8_lossy(&svg.data).contains("data:image/png;base64,"));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
}
|
||||
// Vector art of more than 1 MiB of SVG keeps its text: only embedded images count.
|
||||
let rects: String =
|
||||
(0..30_000).map(|i| format!(r##"<rect x="{}.25" y="{}.75" width="3.5" height="3.5" fill="#{:06x}"/>"##, i % 400, i / 100, i * 977)).collect();
|
||||
run(&mut s, "clipboard.importSvg", json!({"svg": format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">{rects}</svg>"#)}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let f = s.clipboard_flavours();
|
||||
assert!(f[0].mime == TEXT && f[0].data.len() > 1 << 20, "{} bytes of text", f[0].data.len());
|
||||
}
|
||||
|
||||
/// The Type tool pastes the text it copied with its formatting, unless pasted text is kept plain.
|
||||
fn paste_copied_range(plain: bool) -> f64 {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Group, layer and type appearance through commands: their own fills, strokes and effects, the
|
||||
//! Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers.
|
||||
//! Group, layer, type and image appearance through commands: their own fills, strokes and effects,
|
||||
//! the Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -161,6 +161,134 @@ fn type_with_an_inline_graphic_stays_type_when_its_own_stroke_has_an_effect() {
|
||||
assert!(matches!(baked.node(t).unwrap().kind, NodeKind::Text(_)));
|
||||
}
|
||||
|
||||
/// A selected 30×30 red image placed with its frame from (50, 50) to (80, 80).
|
||||
fn image(s: &mut Session) -> NodeId {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let r = run(s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_image_fills_and_strokes_go_below_the_image() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
// Each lands right below the Contents row: [stroke, fill | image].
|
||||
let ap = node(&s, im).appearance;
|
||||
assert!(!ap.items[0].is_fill() && ap.items[1].is_fill() && ap.contents_at() == 2, "{ap:?}");
|
||||
assert_eq!(run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}))["contents"], 1);
|
||||
assert_eq!(node(&s, im).appearance.contents_at(), 1);
|
||||
// The active row above the image stays active when a new item goes in below it.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
assert_eq!(s.appearance_item(), Some(2));
|
||||
assert!(node(&s, im).appearance.items[2].is_fill());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_frame() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 4, "color": "#000000"}));
|
||||
// Without an effect the stroke draws nothing, and Use Preview Bounds measures the frame.
|
||||
assert_eq!(pixel(&s, 49, 65), [255; 4]);
|
||||
s.prefs.use_preview_bounds = true;
|
||||
assert_eq!(s.transform_bounds(&[im]), Some(vectorcraft_geom::Rect::new(50.0, 50.0, 80.0, 80.0)));
|
||||
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
// Under the image, the stroke's outer 2 pt shows.
|
||||
assert_eq!((pixel(&s, 48, 65), pixel(&s, 49, 65), pixel(&s, 51, 65)), ([0, 0, 0, 255], [0, 0, 0, 255], RED));
|
||||
assert_eq!(pixel(&s, 47, 65), [255; 4]);
|
||||
// SVG and PDF hold the image and the frame stroked 4 pt.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert!(svg.contains("<image") && svg.contains("stroke-width=\"4\""), "{svg}");
|
||||
let back = vectorcraft_pdf::import(&vectorcraft_pdf::export(&doc, &Default::default()).unwrap()).unwrap();
|
||||
let (mut images, mut frames) = (0, 0);
|
||||
for l in &back.layers {
|
||||
l.walk(&mut |n| {
|
||||
images += usize::from(matches!(n.kind, NodeKind::Image(_)));
|
||||
let framed = n.geometric_bounds().is_some_and(|b| [b.x0 - 50.0, b.y0 - 50.0, b.x1 - 80.0, b.y1 - 80.0].iter().all(|d| d.abs() < 0.01));
|
||||
frames += usize::from(matches!(n.kind, NodeKind::Path { .. }) && framed && (n.appearance.stroke_width() - 4.0).abs() < 0.01);
|
||||
});
|
||||
}
|
||||
assert_eq!((images, frames), (1, 1));
|
||||
// Expand Appearance leaves the stroke's outline (a filled path) below the image, in one step.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let n = node(&s, im);
|
||||
let kids = n.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{n:?}");
|
||||
assert!(matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
|
||||
let outline = kids[0].visual_bounds().unwrap();
|
||||
assert!((outline.x0 - 48.0).abs() < 0.01 && (outline.x1 - 82.0).abs() < 0.01, "{outline:?}");
|
||||
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::BLACK));
|
||||
}
|
||||
|
||||
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
|
||||
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for k in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
|
||||
s.pointer(&vectorcraft_tools::PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
s.active().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_an_images_stroke_or_fill_outside_its_frame_selects_the_image() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
// A 4 pt stroke 12 pt outside the frame: it paints from x 36 to 40, the frame starts at 50.
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 4, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 12}, "item": 0}));
|
||||
assert_eq!(pixel(&s, 38, 65), [0, 0, 0, 255]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 38.0, 65.0), [im]);
|
||||
// Between the stroke and the frame, and outside the stroke, nothing paints.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(click(&mut s, 45.0, 65.0).is_empty());
|
||||
assert!(click(&mut s, 30.0, 65.0).is_empty());
|
||||
// A fill that Convert to Shape makes 9 pt larger than the frame on each side.
|
||||
run(&mut s, "select.set", json!({"ids": [im.0]}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#00ff00", "item": 1}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "convertToShape.rectangle", "item": 1}));
|
||||
assert_eq!(pixel(&s, 44, 65), GREEN);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 44.0, 65.0), [im]);
|
||||
// In a group, the stroke selects the group.
|
||||
let r = id_of(&run(&mut s, "shape.rectangle", json!({"x": 120, "y": 120, "width": 20, "height": 20})));
|
||||
run(&mut s, "select.set", json!({"ids": [im.0, r.0]}));
|
||||
let g = id_of(&run(&mut s, "object.group", json!({})));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 38.0, 65.0), [g]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_copy_of_an_images_stroke_that_a_transform_effect_makes_selects_the_image() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 4, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
// One copy 60 pt to the right: its stroke paints from x 138 to 142 on the right.
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 60, "copies": 1}, "item": null}));
|
||||
assert_eq!(pixel(&s, 141, 65), [0, 0, 0, 255]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 141.0, 65.0), [im]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(click(&mut s, 147.0, 65.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_appearance_round_trips_and_expands() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -48,7 +48,7 @@ fn type_gives_font_properties_and_gradients_css_gradients() {
|
||||
let mut s = session();
|
||||
let t = run(&mut s, "text.create", json!({"x": 20, "y": 50, "text": "Title", "size": 24}))["id"].as_u64().unwrap();
|
||||
let text = css(&run(&mut s, "css.selection", json!({"ids": [t]}))).to_string();
|
||||
for decl in ["font-family: 'Source Sans 3';", "font-size: 24px;", "color: #"] {
|
||||
for decl in ["font-family: 'Source Sans 3', sans-serif;", "font-size: 24px;", "color: #"] {
|
||||
assert!(text.contains(decl), "{decl} in {text}");
|
||||
}
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
|
||||
|
||||
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user