mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 07:04:10 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2438d38084 | ||
|
|
d2e349ee6c | ||
|
|
78b46b1a99 | ||
|
|
3458eee861 | ||
|
|
38bc5e916c | ||
|
|
f4bc90b93f | ||
|
|
3ec52a9ff1 | ||
|
|
9b18f57344 | ||
|
|
ad694eb17d | ||
|
|
ba40dffb25 | ||
|
|
ce85a5ae78 | ||
|
|
33214b70f4 | ||
|
|
989a685b3f | ||
|
|
3b04ff873c | ||
|
|
08fa0ed4fb | ||
|
|
a335b58e21 | ||
|
|
f32f882f3a | ||
|
|
21a952698a | ||
|
|
1a388f8ba7 | ||
|
|
64d042aef5 | ||
|
|
0e9cbbc88e | ||
|
|
719fb44d42 | ||
|
|
3b16c2bc98 | ||
|
|
bd133eee81 | ||
|
|
43e9587590 | ||
|
|
20adb5f1d7 | ||
|
|
1bc45862cb | ||
|
|
64526a6c28 | ||
|
|
f97c28828d | ||
|
|
1837a19362 | ||
|
|
29388de427 | ||
|
|
c71322043c | ||
|
|
e4a413241a | ||
|
|
403dc5043a | ||
|
|
07e4020360 | ||
|
|
3b6fabb04a | ||
|
|
a06c4639f6 | ||
|
|
7b81efddcb | ||
|
|
dd83f87d3c | ||
|
|
0fe1d3233d | ||
|
|
c865e3b93b | ||
|
|
b9b97f15ff | ||
|
|
5eada4996a | ||
|
|
abfb8cce96 | ||
|
|
fa5a06de4f | ||
|
|
fb9da6d581 | ||
|
|
a870371600 | ||
|
|
f38e0e40bd | ||
|
|
9ce8ab897c | ||
|
|
a0d6bf2dcd | ||
|
|
b75f0cf21d | ||
|
|
f3c20b9242 | ||
|
|
b0833a893c | ||
|
|
5189019b5b | ||
|
|
c2d5a3fd64 | ||
|
|
7b4dfb7ff6 | ||
|
|
6695bd5a8b | ||
|
|
f8bea646d4 | ||
|
|
1d278f3061 | ||
|
|
a194e0ebb2 | ||
|
|
7ab402c025 | ||
|
|
4a04cb6d14 | ||
|
|
3a7180cf8c | ||
|
|
8bb5f31e73 | ||
|
|
419f6ef4c7 | ||
|
|
5ef5c71164 | ||
|
|
e09bf5728f | ||
|
|
97a1a67f38 | ||
|
|
f0137c6ace | ||
|
|
1bf99d9866 | ||
|
|
db33aa6ad4 | ||
|
|
e7964eb78e | ||
|
|
e135613187 | ||
|
|
4c46f0c0fc | ||
|
|
149fdb19b6 | ||
|
|
e730ebf07b | ||
|
|
38f684e4e4 | ||
|
|
d71ff0d34d | ||
|
|
5c8adfe81d | ||
|
|
574235c98a | ||
|
|
43b1488325 | ||
|
|
34a1089140 | ||
|
|
04d66caccd | ||
|
|
e07d293d5b | ||
|
|
17f149e73c | ||
|
|
6da0673cd1 | ||
|
|
ff5708361d | ||
|
|
ce3fff235e | ||
|
|
743a38e64d | ||
|
|
5933793982 | ||
|
|
8cc64b86a1 | ||
|
|
ef361309c8 | ||
|
|
15943f5126 | ||
|
|
46fe7f6a69 | ||
|
|
925cb86c4b | ||
|
|
6d8cbac7dc | ||
|
|
18e2d45420 | ||
|
|
87fa191818 | ||
|
|
ca18689304 | ||
|
|
8e518d8169 | ||
|
|
a1b56b2d72 | ||
|
|
45c7b57b6a | ||
|
|
d5b6da319e | ||
|
|
f3dbc0fcfb | ||
|
|
66e54580b7 | ||
|
|
8aca796773 | ||
|
|
d5bf6f0398 | ||
|
|
0c965366d5 | ||
|
|
9e55e81677 | ||
|
|
02eeecd59f | ||
|
|
f6ed54e83e | ||
|
|
54229918c9 | ||
|
|
be2760a7bc | ||
|
|
f471a08224 | ||
|
|
0001f4d0a3 | ||
|
|
8e84c55296 | ||
|
|
8e66e5fcdd | ||
|
|
418c70acb2 | ||
|
|
a6702058db | ||
|
|
94d28535d3 | ||
|
|
ef62950bff | ||
|
|
8baf2ef75b | ||
|
|
c5630b2a5d | ||
|
|
f90f4b164a | ||
|
|
da9de5c07f | ||
|
|
ebd173de80 | ||
|
|
548eeafcee | ||
|
|
0c18dbff9a | ||
|
|
30c420761d | ||
|
|
6a663c107b | ||
|
|
1d6ba33d76 | ||
|
|
b4c64e2fd5 | ||
|
|
c32ff12f96 | ||
|
|
17ad0d74b7 | ||
|
|
4570ad3333 | ||
|
|
dccd9f23e1 | ||
|
|
f1ce0770aa | ||
|
|
c653757ad5 | ||
|
|
a0acbec162 | ||
|
|
479eeeae19 | ||
|
|
27466a8e58 | ||
|
|
f4ea3139d7 | ||
|
|
a6b950c4fc | ||
|
|
d93458b72b | ||
|
|
f4d54906eb | ||
|
|
e74f4758d8 | ||
|
|
e57452d945 | ||
|
|
27581e4d87 | ||
|
|
7021fa7a7c | ||
|
|
61fb0efaef | ||
|
|
8cc639d9b6 | ||
|
|
c27f1670d0 | ||
|
|
38ca7288a5 | ||
|
|
68d8130b77 | ||
|
|
773ccdf25a | ||
|
|
879ad9d149 | ||
|
|
65f7150ccd | ||
|
|
26df98e878 | ||
|
|
8c97f411ba | ||
|
|
4d837a9269 | ||
|
|
6848c92e28 | ||
|
|
f784980d34 | ||
|
|
aa715535f3 | ||
|
|
4cce737673 | ||
|
|
23d564165f | ||
|
|
a7825b9e0f | ||
|
|
556931ad90 | ||
|
|
29c073b75c | ||
|
|
a936a93464 | ||
|
|
0034c989e6 | ||
|
|
9bf386adc9 | ||
|
|
44a9161960 | ||
|
|
8564c74861 | ||
|
|
a8a6894ca7 | ||
|
|
e7ddf6d7c7 | ||
|
|
5baa87b131 | ||
|
|
3be9e7bcb1 | ||
|
|
e07df7877f | ||
|
|
a425efcb2a | ||
|
|
57aef126b0 | ||
|
|
aa1a1ea5d4 | ||
|
|
19b96d5351 | ||
|
|
066aae7233 | ||
|
|
f9587384c7 | ||
|
|
e027e048fc | ||
|
|
51d26f36bc | ||
|
|
7ad35873b8 | ||
|
|
86eb4e6739 | ||
|
|
cbc091dd68 | ||
|
|
77557d75f1 | ||
|
|
616ab9d56a | ||
|
|
3d3b67b74c | ||
|
|
76e4e81e33 | ||
|
|
66ff3bf7eb | ||
|
|
d13facdc28 | ||
|
|
dcfac95286 | ||
|
|
06d55f7adb | ||
|
|
3c7ee9eded | ||
|
|
019eb273cc | ||
|
|
7e7defd0fb | ||
|
|
960d927a37 | ||
|
|
d08a16507c | ||
|
|
24970b1622 | ||
|
|
f5161d9cb1 | ||
|
|
1069d30cb1 | ||
|
|
0750751261 | ||
|
|
d5d937ac3e | ||
|
|
be9cca9bd9 | ||
|
|
a81d472f5a | ||
|
|
cc479f604a | ||
|
|
f343abda5d | ||
|
|
1d3e94aa4c | ||
|
|
643c9b7044 | ||
|
|
da307f8f17 | ||
|
|
8352b26c1a | ||
|
|
4decb8dd30 | ||
|
|
4bc06b906b | ||
|
|
23e9b9ccf8 | ||
|
|
87862a6f05 | ||
|
|
6686b22d91 | ||
|
|
645e47e46d | ||
|
|
0efeabf7ad | ||
|
|
47a7f8021b | ||
|
|
cd407f1a05 | ||
|
|
4010044b7b | ||
|
|
87e5d77b0e | ||
|
|
dd99f53662 | ||
|
|
551241d408 | ||
|
|
7201189695 | ||
|
|
60cd4ad099 | ||
|
|
d073b42290 | ||
|
|
a67b6ed5ca | ||
|
|
d3a5c28904 | ||
|
|
52640de2b5 | ||
|
|
c69e6efe07 | ||
|
|
66abf411ad | ||
|
|
64534add6d | ||
|
|
375cf82c10 | ||
|
|
d98e03903d | ||
|
|
e5f4f8e260 | ||
|
|
901e19d30b | ||
|
|
91a28160ff | ||
|
|
a2b5e4de4f | ||
|
|
e9b6710b89 | ||
|
|
c2dbd974eb | ||
|
|
3c77d1d6aa | ||
|
|
afa8b234ce | ||
|
|
19f9f50042 | ||
|
|
bac554b181 | ||
|
|
5abe294af7 | ||
|
|
2ed222416d | ||
|
|
9164d2eebc | ||
|
|
b68b64ccb5 | ||
|
|
86811ddeee | ||
|
|
5bf2898f4e | ||
|
|
d276fce259 | ||
|
|
a492716367 | ||
|
|
213eaa260d | ||
|
|
b16910427c | ||
|
|
27b8d133d8 | ||
|
|
4cfb576ee0 | ||
|
|
e7ff19f283 | ||
|
|
89dbbc9aca | ||
|
|
e7d82f219b | ||
|
|
f5543e85fc | ||
|
|
3d7d62e349 | ||
|
|
385d2a7a01 | ||
|
|
8fa1217bcc | ||
|
|
df7c4350eb | ||
|
|
213b95e17c | ||
|
|
ae51ce8d82 | ||
|
|
ec13017dfb | ||
|
|
f5a0587945 | ||
|
|
8177fe2d45 | ||
|
|
576b7cdfcb | ||
|
|
3bcd357738 | ||
|
|
f44bf80b86 | ||
|
|
b0acb119d6 | ||
|
|
ec51ae55da | ||
|
|
d7d562287b | ||
|
|
967fd444ae | ||
|
|
7a712f96df | ||
|
|
ba7e4e9e6f | ||
|
|
ac1a7443a9 | ||
|
|
9c280d4c58 | ||
|
|
57948f1155 | ||
|
|
66c33840e0 | ||
|
|
c00045d7d9 | ||
|
|
bbcc832103 | ||
|
|
1133cb5a40 | ||
|
|
6dbc8956ad | ||
|
|
98b4574545 | ||
|
|
1b84acd2e3 | ||
|
|
cd6e288820 | ||
|
|
d0d5e31019 | ||
|
|
18b176c41e | ||
|
|
168701779c | ||
|
|
404a17e711 | ||
|
|
567a342296 | ||
|
|
c9b0bb0cbc | ||
|
|
b484f45429 | ||
|
|
399e6a43c5 | ||
|
|
d149007d0b | ||
|
|
152c9677da | ||
|
|
d41aae7849 | ||
|
|
d1f6ab4f1b | ||
|
|
2f002a4545 | ||
|
|
627bb8f1bf | ||
|
|
ff61346024 | ||
|
|
a75039792e | ||
|
|
60c05073a5 | ||
|
|
0073b395d4 | ||
|
|
723dbe943f | ||
|
|
2d7c62055e | ||
|
|
3b63a27a88 | ||
|
|
480c12304d | ||
|
|
d104f7015f | ||
|
|
3bd2274aa2 | ||
|
|
c0eb60dc84 | ||
|
|
f10194c028 | ||
|
|
0a7325af6e | ||
|
|
e81fa94f3f | ||
|
|
d470faa65f | ||
|
|
45ed3ccb2e | ||
|
|
0ed0f286ce | ||
|
|
d22b6ec7b4 | ||
|
|
40b3bf9b6b | ||
|
|
f6ed751a27 | ||
|
|
b19293d293 | ||
|
|
1bd60ab6fb | ||
|
|
67a9c53f26 | ||
|
|
a27fa7a19b | ||
|
|
4d6d735ff9 | ||
|
|
7296d0d8b2 | ||
|
|
566bccf6bb | ||
|
|
5f8b124efa | ||
|
|
5634b5155a | ||
|
|
ff2db1b83b | ||
|
|
52312331d8 | ||
|
|
38611da546 | ||
|
|
e51f48d0e8 | ||
|
|
9819df5002 | ||
|
|
d26d32cafe | ||
|
|
4fea6ffda4 | ||
|
|
53c3ff543b | ||
|
|
d51202c5a4 | ||
|
|
69af55ff7e | ||
|
|
cf8a79190d | ||
|
|
53e3a059ee | ||
|
|
5be2b29463 | ||
|
|
a99277109c | ||
|
|
034788019e | ||
|
|
824f93a69f | ||
|
|
e1a7065071 | ||
|
|
468b96d57d | ||
|
|
dbdd8ff66e | ||
|
|
65879ba0fb | ||
|
|
8f0a484298 | ||
|
|
9f1083d31e | ||
|
|
b3224447a9 | ||
|
|
d824505598 | ||
|
|
ab16e0b4b7 | ||
|
|
d11f2b6c84 | ||
|
|
2e845117b9 | ||
|
|
16154d2cff | ||
|
|
3c6d1088a1 | ||
|
|
769cd2f725 | ||
|
|
5a17d92f03 | ||
|
|
4a53af3598 | ||
|
|
8591ca1904 | ||
|
|
c5ba77e5d9 | ||
|
|
f1a3e61268 | ||
|
|
a5ebdaaa30 | ||
|
|
a0983e6105 | ||
|
|
818de14222 | ||
|
|
5faf499d5c | ||
|
|
bff2609a68 | ||
|
|
abd3755d41 | ||
|
|
7c504d79c2 | ||
|
|
8b083b6e5b | ||
|
|
ec0fc7cb0e | ||
|
|
59b03f2948 | ||
|
|
b287a884f4 | ||
|
|
07b9600561 | ||
|
|
ceaac3dc25 | ||
|
|
a66e6ed0bb | ||
|
|
2c96d9faa0 | ||
|
|
fa9f11c7a7 | ||
|
|
c9889c8128 | ||
|
|
475df78c32 | ||
|
|
b960c3a4e2 | ||
|
|
34b5754efd | ||
|
|
cd781bb25f | ||
|
|
e4546cc0bc | ||
|
|
84a1bf37f2 | ||
|
|
f9d883b5de | ||
|
|
806e7ba37a | ||
|
|
9af604e90c | ||
|
|
4490717400 | ||
|
|
a58a6b100f | ||
|
|
4b9fde8787 | ||
|
|
dc6cb9725b | ||
|
|
f4764f1f99 | ||
|
|
a9a757543d | ||
|
|
a84aa1be34 | ||
|
|
21fa6cf1dd | ||
|
|
21cca70465 | ||
|
|
05bd004bc4 | ||
|
|
b10c9b91f1 | ||
|
|
4277f12437 | ||
|
|
f309665694 | ||
|
|
674bfb697b | ||
|
|
1f6dbf8415 | ||
|
|
e87ad39a21 | ||
|
|
a053f9bc4e | ||
|
|
7835d05a54 | ||
|
|
f64a143c55 | ||
|
|
f1ba4854d0 | ||
|
|
caca15106e | ||
|
|
02a63c94c2 | ||
|
|
0acb707fe1 | ||
|
|
9e22dda917 | ||
|
|
c1f20c239d | ||
|
|
b8b8907f72 | ||
|
|
060a8cced7 | ||
|
|
c642bfbe64 | ||
|
|
00697fb777 | ||
|
|
dfb84a686d | ||
|
|
199b0a122c | ||
|
|
cd44042921 | ||
|
|
f99e12ecbd | ||
|
|
0c27aea23a | ||
|
|
5a5de0e73d | ||
|
|
afc817d1fa | ||
|
|
f2a487b088 | ||
|
|
64e7ed1583 | ||
|
|
7bb7819b00 | ||
|
|
01479be23a | ||
|
|
fdb1794014 | ||
|
|
70eab0c230 | ||
|
|
bd5e5ed781 | ||
|
|
86b4c67adf | ||
|
|
9c927993b5 | ||
|
|
8353ecc354 | ||
|
|
e4d4ab4205 | ||
|
|
2a9a35f038 | ||
|
|
8c6166849a | ||
|
|
4615a8bc26 | ||
|
|
4ea30f3673 | ||
|
|
4527b6a7e2 | ||
|
|
8138fc12e7 | ||
|
|
56e8532c86 | ||
|
|
ecc8fb4622 | ||
|
|
7240a69db1 | ||
|
|
fa5aac84c3 | ||
|
|
642baa07c5 | ||
|
|
2a81d271b5 | ||
|
|
f667f8204e | ||
|
|
90992f3f84 | ||
|
|
957e68f600 | ||
|
|
4c0335612e | ||
|
|
87ff8bbc66 | ||
|
|
f72c1d1688 | ||
|
|
6486ca76f4 | ||
|
|
0e8840f157 | ||
|
|
a83ec3d2c9 | ||
|
|
25b79243f1 | ||
|
|
79793acefe | ||
|
|
f7942efd1d | ||
|
|
ccdf082c62 | ||
|
|
f0ef89baf6 | ||
|
|
7a671f1486 | ||
|
|
792413b528 | ||
|
|
62dbd6ced7 | ||
|
|
3f5c65e166 | ||
|
|
d90d4041df | ||
|
|
f43dac99f5 | ||
|
|
be9672619f | ||
|
|
01e1f3f126 | ||
|
|
7ce7a347f8 | ||
|
|
95f5a19a0a | ||
|
|
76ce0aa217 | ||
|
|
f1cfdf40f5 | ||
|
|
e9c1232ce7 | ||
|
|
1158c79c87 | ||
|
|
8b3621fa92 | ||
|
|
6690b8535d | ||
|
|
d88d4a228d | ||
|
|
710c26af41 | ||
|
|
66f91c3e25 | ||
|
|
c8f7cfaae6 | ||
|
|
e8848e8ba2 | ||
|
|
17c9cda345 | ||
|
|
f73df05a84 | ||
|
|
c6e4d8a945 | ||
|
|
3192457c02 | ||
|
|
20385b7043 | ||
|
|
ff5b1228c6 | ||
|
|
59bb90ad1b | ||
|
|
416e546562 | ||
|
|
690a73b595 | ||
|
|
d1ee15a189 | ||
|
|
087e540c6b | ||
|
|
289ad2bb3e | ||
|
|
6ab5b734e1 | ||
|
|
89522c5859 | ||
|
|
214b1a0710 | ||
|
|
a2372a9ce2 | ||
|
|
459a25bd95 | ||
|
|
ab668a1dff | ||
|
|
fc298ebf12 | ||
|
|
4b88e94f91 | ||
|
|
33398aeb1c | ||
|
|
cf2d819fad | ||
|
|
f921d9af81 | ||
|
|
7414914290 | ||
|
|
9e2aa98177 | ||
|
|
4bfaa89723 | ||
|
|
58d4620a8d | ||
|
|
eab7603ead | ||
|
|
f1820242e1 | ||
|
|
9f74db6980 | ||
|
|
3d53024a99 | ||
|
|
d35c5032ed | ||
|
|
fcea662989 | ||
|
|
2430664dd3 | ||
|
|
d685aa1484 | ||
|
|
87e8e5977c | ||
|
|
fadeb802ef | ||
|
|
6bda896ad0 | ||
|
|
193e86a66f | ||
|
|
a6b5208aad | ||
|
|
e727c3dcf8 | ||
|
|
2197040799 | ||
|
|
875ec69fce | ||
|
|
c6fff8a3a8 | ||
|
|
538d9e0ece | ||
|
|
44406fa17e | ||
|
|
bdf8d06926 | ||
|
|
a2809d0040 | ||
|
|
e8834e8d93 | ||
|
|
1be48a8360 | ||
|
|
cd9ec5c03e | ||
|
|
476a0fc3ef | ||
|
|
b9dda08d01 | ||
|
|
81a4342b18 | ||
|
|
85acfeee65 | ||
|
|
3f89289b84 | ||
|
|
7072965705 | ||
|
|
d356513052 | ||
|
|
0769fa22ff | ||
|
|
3e373ddef8 | ||
|
|
41bc07d8f5 | ||
|
|
257f0a0d31 | ||
|
|
606b2dadba | ||
|
|
ff411fa9e1 | ||
|
|
80dcc22a70 | ||
|
|
aba7cfe85e | ||
|
|
b0350c64c1 | ||
|
|
9b5cb29395 | ||
|
|
a3d72f6f6a | ||
|
|
cdfa71557d | ||
|
|
f0543a19ed | ||
|
|
0ab262a0e3 | ||
|
|
444ee06e1a | ||
|
|
3c91200d88 | ||
|
|
ac8e60ff2e | ||
|
|
801e229d76 | ||
|
|
a80ece00aa | ||
|
|
5d9093bf0c | ||
|
|
b6be26a490 | ||
|
|
8cedb81a63 | ||
|
|
73459a662c | ||
|
|
eee57c02ce | ||
|
|
c1c44507d1 | ||
|
|
5f9c7d307e | ||
|
|
81b6cdf0d2 | ||
|
|
f57bb1f3b1 | ||
|
|
b228c1c0c2 | ||
|
|
50a011ae9e | ||
|
|
f571673fc2 | ||
|
|
8251a722b9 | ||
|
|
6b7f08fcd6 | ||
|
|
26cdfae307 | ||
|
|
eae5eecab5 | ||
|
|
0a353ae85e | ||
|
|
e47b4ddc1c | ||
|
|
c68541dc26 | ||
|
|
7635c61f89 | ||
|
|
3227bf8366 | ||
|
|
92aa5047cd | ||
|
|
2e55f5eaa1 | ||
|
|
79a8a795e3 | ||
|
|
e3f95672df | ||
|
|
86d1630de9 | ||
|
|
1f745a9d45 | ||
|
|
7d6712603d | ||
|
|
a5f8463e9b | ||
|
|
b90ddb338a | ||
|
|
99cfcf40a7 | ||
|
|
cae5a506c0 | ||
|
|
48e12118d1 | ||
|
|
8df66a71ca | ||
|
|
e8f6b89c9c | ||
|
|
6dffa480ac | ||
|
|
5b026c1979 | ||
|
|
17d0a55baf | ||
|
|
b54a4a8f86 | ||
|
|
ad236be126 | ||
|
|
49aba9f2bd | ||
|
|
0e009c246f | ||
|
|
1d1cb4e462 | ||
|
|
4499458d32 | ||
|
|
5cc5a9ed13 | ||
|
|
a40b95f250 | ||
|
|
d16b4f7302 | ||
|
|
097848ddb5 | ||
|
|
1b142a4aa7 | ||
|
|
b92f49a6cb | ||
|
|
70886c0d88 | ||
|
|
f784275180 | ||
|
|
1c7989f607 | ||
|
|
2c5ed1c91d | ||
|
|
478c986972 | ||
|
|
c8ff8e3860 | ||
|
|
fef5e042bd | ||
|
|
186dbfe4cd | ||
|
|
08717e7bcf | ||
|
|
8313dadcc5 | ||
|
|
014207fa51 | ||
|
|
9e953aa897 | ||
|
|
dc0aba4be2 | ||
|
|
aef99241eb | ||
|
|
8d9668bf87 | ||
|
|
82ea23e9a3 | ||
|
|
f43f9df2d7 | ||
|
|
dfb44a5e3e | ||
|
|
e86852ac83 | ||
|
|
288f85453b | ||
|
|
12ec8fceea | ||
|
|
6d37936db3 | ||
|
|
a7ce975694 | ||
|
|
7505ebdae3 | ||
|
|
641155b3bc | ||
|
|
66ebd86873 | ||
|
|
7fc8b5d8b4 | ||
|
|
b314f9c565 | ||
|
|
74ad8ee5bc | ||
|
|
772cf39c09 | ||
|
|
5af951d2d9 | ||
|
|
ec56c286dd | ||
|
|
6c7b9d879d | ||
|
|
5d753525cc | ||
|
|
3fbdc1a947 | ||
|
|
224af59267 | ||
|
|
b6fece801c | ||
|
|
8bc312ac59 | ||
|
|
6bf45d05ac | ||
|
|
2a54c3a573 | ||
|
|
7fc98eb769 | ||
|
|
9323037883 | ||
|
|
f3848ed6c8 | ||
|
|
c9f986057c | ||
|
|
cfe712c340 | ||
|
|
e1b2d49973 | ||
|
|
475a0745d8 | ||
|
|
44c52a013a | ||
|
|
a42e4f9a2d | ||
|
|
3ce9b2e0b8 | ||
|
|
3ad6011bc5 | ||
|
|
a33b7929a0 | ||
|
|
b8a7c93b4f | ||
|
|
6548e1c194 | ||
|
|
28a3514a73 | ||
|
|
69b1f2544d | ||
|
|
56030984b3 | ||
|
|
0364da6ae9 | ||
|
|
8911a4ffbe | ||
|
|
05af9b146b | ||
|
|
849d064cc8 | ||
|
|
32b1ae2e5a | ||
|
|
338304e712 | ||
|
|
1515cafae4 | ||
|
|
f9f1d823cc | ||
|
|
135dc559a6 | ||
|
|
6e0b3330c9 | ||
|
|
8aebfd3ab3 | ||
|
|
f9795681b2 | ||
|
|
b15b5a7587 |
No files matched your search
+16
-14
@@ -113,7 +113,7 @@ jobs:
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
# A failing test still saves: the cache holds compiled dependencies, not test results.
|
||||
# The Windows legs share one key (same dependencies); the integration leg saves it,
|
||||
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
|
||||
cache-on-failure: true
|
||||
@@ -251,9 +251,9 @@ jobs:
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
shared-key: test
|
||||
save-if: false
|
||||
@@ -306,8 +306,8 @@ jobs:
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: corpus
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -328,10 +328,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -351,17 +351,17 @@ jobs:
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: ci-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
|
||||
@@ -419,15 +419,17 @@ jobs:
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: crates/${{ matrix.target.crate }}/fuzz
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Fuzz
|
||||
working-directory: crates/${{ matrix.target.crate }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
||||
env:
|
||||
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- name: Install mdBook
|
||||
run: cargo install mdbook --version 0.4.52 --locked
|
||||
- name: Build documentation
|
||||
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: perf-release
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
|
||||
## 2. Workspace map
|
||||
|
||||
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
|
||||
|
||||
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
|
||||
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
|
||||
|
||||
+4
-1
@@ -27,9 +27,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -110,11 +111,13 @@ next to the files:
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
|
||||
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
|
||||
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
|
||||
| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ RUN set -eu; \
|
||||
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
|
||||
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
|
||||
cd /tmp; \
|
||||
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
|
||||
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
|
||||
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
|
||||
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
|
||||
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
|
||||
tar -xzf "$asset" -C /usr/local/bin trunk; \
|
||||
rm "$asset" "$asset.sha256"; \
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">PhotoCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -202,7 +206,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -17,11 +17,11 @@ use wasm_bindgen::JsCast as _;
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
|
||||
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
|
||||
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
|
||||
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -322,6 +322,12 @@ impl WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
|
||||
// twin press closes the menu the first one opened (#2095).
|
||||
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
if !dropped.is_empty() {
|
||||
|
||||
@@ -34,6 +34,13 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
|
||||
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
|
||||
}
|
||||
|
||||
/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`]
|
||||
/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon
|
||||
/// on the Dock after the window opens (#7253).
|
||||
pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData {
|
||||
if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() }
|
||||
}
|
||||
|
||||
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
|
||||
fn in_app_bundle(exe: &std::path::Path) -> bool {
|
||||
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
@@ -80,6 +87,19 @@ mod tests {
|
||||
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
|
||||
}
|
||||
|
||||
/// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded.
|
||||
#[test]
|
||||
fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() {
|
||||
use std::path::Path;
|
||||
let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"));
|
||||
if sets_runtime_icon(bundled) {
|
||||
let icon = viewport_icon(bundled);
|
||||
assert!(icon.width >= 256 && icon.width == icon.height);
|
||||
} else {
|
||||
assert_eq!(viewport_icon(bundled), egui::IconData::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
|
||||
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
|
||||
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
|
||||
//! for one launch.
|
||||
//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows
|
||||
//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first
|
||||
//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything,
|
||||
//! and `--safe-gpu` forces the CPU path for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
|
||||
@@ -35,6 +35,10 @@ use serde_json::{Value, json};
|
||||
pub const MARKER_FILE: &str = "gpu-starting.json";
|
||||
/// PCI vendor id of Intel.
|
||||
pub const INTEL: u32 = 0x8086;
|
||||
/// Qualcomm's vendor id as its Vulkan driver reports it.
|
||||
pub const QUALCOMM: u32 = 0x5143;
|
||||
/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM".
|
||||
pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351;
|
||||
/// Largest marker read (a marker is a few hundred bytes).
|
||||
const MARKER_MAX: u64 = 64 << 10;
|
||||
|
||||
@@ -244,6 +248,18 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's
|
||||
/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on
|
||||
/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same
|
||||
/// machine didn't, #7537). The name is for System Info.
|
||||
fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> {
|
||||
match vendor {
|
||||
INTEL => Some("Intel"),
|
||||
QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What adapter selection needs to know about an adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
@@ -258,9 +274,10 @@ pub struct Candidate {
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
|
||||
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
|
||||
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
|
||||
/// Intel and Qualcomm on Windows ([`dx12_first_on_windows`]). For `cpu`, any GL adapter comes
|
||||
/// before Vulkan, which is only the fallback for presenting the window (see [`backends`]). On
|
||||
/// Windows GL comes after every other adapter, WARP included: GL there (VMware's SVGA3D, GL-only
|
||||
/// old GPUs) drew a black window (#5191, #1788).
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
|
||||
let ty = match a.device_type {
|
||||
@@ -276,7 +293,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
_ => ty,
|
||||
};
|
||||
let be = match a.backend {
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
|
||||
wgpu::Backend::Dx12 => 2,
|
||||
wgpu::Backend::Gl => 3,
|
||||
@@ -329,15 +346,18 @@ pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
|
||||
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
|
||||
/// the instance).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
|
||||
/// The maker's name when picking `chosen` applied the Windows DX12 default of
|
||||
/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel,
|
||||
/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance).
|
||||
pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> {
|
||||
let c = adapters.get(chosen)?;
|
||||
if os != Os::Windows || c.backend != wgpu::Backend::Dx12 {
|
||||
return None;
|
||||
}
|
||||
let maker = dx12_first_on_windows(c.vendor)?;
|
||||
let applied =
|
||||
(vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker));
|
||||
applied.then_some(maker)
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
@@ -349,8 +369,8 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update
|
||||
/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
@@ -396,9 +416,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
|
||||
if backend == GpuBackend::Auto
|
||||
&& let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
|
||||
{
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
|
||||
}
|
||||
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
|
||||
}));
|
||||
@@ -772,13 +794,13 @@ mod tests {
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
|
||||
assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
|
||||
assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
@@ -789,6 +811,27 @@ mod tests {
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
/// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the
|
||||
/// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids.
|
||||
#[test]
|
||||
fn qualcomm_on_windows_defaults_to_dx12() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)];
|
||||
let i = pick(&adreno, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(adreno[i].backend, B::Dx12);
|
||||
assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm"));
|
||||
// Only Vulkan offered: still Vulkan, and no note.
|
||||
assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0));
|
||||
assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None);
|
||||
// An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default".
|
||||
let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]];
|
||||
assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None);
|
||||
// Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel.
|
||||
assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None);
|
||||
// Elsewhere Vulkan stays first.
|
||||
assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
}
|
||||
|
||||
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
|
||||
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
|
||||
/// Windows creates the instance without Vulkan.
|
||||
@@ -824,8 +867,8 @@ mod tests {
|
||||
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&listed, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(i, 0);
|
||||
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
|
||||
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
|
||||
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel"));
|
||||
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -102,9 +102,7 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
||||
/// different offset every launch (#419). A later launch restores the saved position instead
|
||||
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
let viewport = egui::ViewportBuilder::default();
|
||||
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
|
||||
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
|
||||
let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
|
||||
eframe::NativeOptions {
|
||||
viewport: viewport
|
||||
.with_app_id(APP_ID)
|
||||
@@ -803,8 +801,9 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking.
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
// The window still draws with wgpu: record its errors instead of panicking,
|
||||
// and notice when the device is lost (#3438).
|
||||
app.watch_window_device(&rs.device);
|
||||
}
|
||||
}
|
||||
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
|
||||
|
||||
@@ -17,12 +17,12 @@ use std::sync::Arc;
|
||||
mod recovery_tests;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
|
||||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
|
||||
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
|
||||
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2",
|
||||
"cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -45,6 +45,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Photoshop", &["psd", "psb", "psdt"]),
|
||||
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
|
||||
("Krita", &["kra", "krz"]),
|
||||
("GIMP", &["xcf", "xcfgz", "gz"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("Paint.NET", &["pdn"]),
|
||||
("SVG", SVG_EXTS),
|
||||
@@ -69,7 +70,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
|
||||
("Brushes", &["abr"]),
|
||||
("Gradients", &["grd"]),
|
||||
("Swatches", &["aco", "ase"]),
|
||||
("Swatches", &["aco", "act", "ase"]),
|
||||
("Keyboard Shortcuts", &["kys"]),
|
||||
];
|
||||
|
||||
@@ -105,7 +106,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
/// never swaps their extension for a document format's.
|
||||
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
|
||||
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
|
||||
|
||||
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
|
||||
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
@@ -386,12 +387,13 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
|
||||
fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
let recover_dir = dir.clone();
|
||||
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
|
||||
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
|
||||
let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone());
|
||||
Services {
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
|
||||
})),
|
||||
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
|
||||
flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())),
|
||||
discard_autosave: Some(Box::new(move |id: u64| {
|
||||
let _ = with_store(&s2, |s| s.discard(id));
|
||||
})),
|
||||
@@ -830,6 +832,9 @@ mod tests {
|
||||
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
|
||||
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
|
||||
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
|
||||
let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default();
|
||||
assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"]));
|
||||
assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD")));
|
||||
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
|
||||
}
|
||||
|
||||
@@ -859,6 +864,10 @@ mod tests {
|
||||
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
|
||||
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
|
||||
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
|
||||
assert_eq!(
|
||||
open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts),
|
||||
Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! copy of the program.
|
||||
//!
|
||||
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
|
||||
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
|
||||
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
|
||||
//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak
|
||||
//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit
|
||||
//! token to `instance.json`. A later launch that finds the lock taken sends its
|
||||
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
|
||||
//! brings the window forward. Explorer starts one process per file when several are opened at once;
|
||||
//! the lock decides the race, so they all end up as tabs in one window.
|
||||
@@ -26,7 +27,11 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -38,6 +43,9 @@ use serde_json::{Value, json};
|
||||
|
||||
const LOCK_FILE: &str = "instance.lock";
|
||||
const INFO_FILE: &str = "instance.json";
|
||||
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
|
||||
#[cfg(unix)]
|
||||
const SOCKET_FILE: &str = "instance.sock";
|
||||
/// More paths than this in one request is not a file manager.
|
||||
const MAX_PATHS: usize = 4096;
|
||||
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
|
||||
@@ -59,6 +67,45 @@ pub struct Timing {
|
||||
/// Generous enough for a primary that is still opening its window.
|
||||
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
|
||||
|
||||
/// How the primary listens for later launches.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Transport {
|
||||
/// A loopback port (the default): private to the machine, reachable by every process on it.
|
||||
Loopback,
|
||||
/// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each
|
||||
/// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never
|
||||
/// reaches the first (#1540). `$XDG_RUNTIME_DIR/app/<app id>/` is the same directory in every
|
||||
/// sandbox of the app, and the user's alone (0700).
|
||||
#[cfg(unix)]
|
||||
Socket(PathBuf),
|
||||
}
|
||||
|
||||
/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is
|
||||
/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port.
|
||||
pub fn transport() -> Transport {
|
||||
#[cfg(unix)]
|
||||
if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) {
|
||||
return Transport::Socket(path);
|
||||
}
|
||||
Transport::Loopback
|
||||
}
|
||||
|
||||
/// `$XDG_RUNTIME_DIR/app/<FLATPAK_ID>/instance.sock`, when both are set.
|
||||
#[cfg(unix)]
|
||||
fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
|
||||
let id = flatpak_id.filter(|v| !v.is_empty())?;
|
||||
let dir = runtime_dir.filter(|v| !v.is_empty())?;
|
||||
Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE))
|
||||
}
|
||||
|
||||
/// Where the running primary listens, as `instance.json` records it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Endpoint {
|
||||
Port(u16),
|
||||
#[cfg(unix)]
|
||||
Socket(PathBuf),
|
||||
}
|
||||
|
||||
/// Outcome of [`claim`].
|
||||
pub enum Launch {
|
||||
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
|
||||
@@ -110,6 +157,10 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
|
||||
pub struct Primary {
|
||||
lock: Mutex<Option<File>>,
|
||||
shared: Arc<Shared>,
|
||||
/// The socket file to remove on the first shutdown (a Unix socket transport only); taken
|
||||
/// then, so the second call (`Drop`) can't remove the socket the next primary has bound.
|
||||
#[cfg(unix)]
|
||||
socket: Mutex<Option<PathBuf>>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -151,6 +202,10 @@ impl Primary {
|
||||
self.shared.closed.store(true, Ordering::Release);
|
||||
// Dropping the senders refuses every connection still waiting for the UI.
|
||||
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
|
||||
#[cfg(unix)]
|
||||
if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
|
||||
}
|
||||
}
|
||||
@@ -211,9 +266,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch {
|
||||
claim_with(dir, paths, TIMING)
|
||||
}
|
||||
|
||||
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
|
||||
/// (free once a quitting or crashed primary is gone) and then the running primary.
|
||||
/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]).
|
||||
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
claim_in(dir, paths, timing, &transport())
|
||||
}
|
||||
|
||||
/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free
|
||||
/// once a quitting or crashed primary is gone) and then the running primary, wherever
|
||||
/// `instance.json` says it listens.
|
||||
pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
return Launch::Standalone(format!("{}: {e}", dir.display()));
|
||||
}
|
||||
@@ -227,7 +288,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
loop {
|
||||
match lock.try_lock() {
|
||||
Ok(()) => {
|
||||
return match serve(dir, lock, timing) {
|
||||
return match serve(dir, lock, timing, transport) {
|
||||
Ok(primary) => Launch::Primary(primary),
|
||||
Err(e) => Launch::Standalone(e),
|
||||
};
|
||||
@@ -235,8 +296,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
Err(std::fs::TryLockError::WouldBlock) => {}
|
||||
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
|
||||
}
|
||||
if let Some((port, token)) = read_info(dir) {
|
||||
match send(port, &token, paths, deadline) {
|
||||
if let Some((endpoint, token)) = read_info(dir) {
|
||||
match send(&endpoint, &token, paths, deadline) {
|
||||
Ok(()) => return Launch::Forwarded,
|
||||
Err(e) => last = e,
|
||||
}
|
||||
@@ -248,11 +309,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
|
||||
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
|
||||
fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result<Primary, String> {
|
||||
let (listener, endpoint) = Listener::bind(transport)?;
|
||||
let token = generate_token().map_err(|e| e.to_string())?;
|
||||
write_info(dir, port, &token)?;
|
||||
write_info(dir, &endpoint, &token)?;
|
||||
#[cfg(unix)]
|
||||
let socket = match &endpoint {
|
||||
Endpoint::Socket(path) => Some(path.clone()),
|
||||
Endpoint::Port(_) => None,
|
||||
};
|
||||
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
|
||||
let listening = Arc::clone(&shared);
|
||||
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
|
||||
@@ -260,7 +325,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
loop {
|
||||
let stream = match listener.accept() {
|
||||
Ok((stream, _)) => stream,
|
||||
Ok(stream) => stream,
|
||||
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
|
||||
Err(_) => {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
@@ -288,11 +353,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
|
||||
}
|
||||
});
|
||||
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
|
||||
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
|
||||
Ok(Primary {
|
||||
lock: Mutex::new(Some(lock)),
|
||||
shared,
|
||||
#[cfg(unix)]
|
||||
socket: Mutex::new(socket),
|
||||
})
|
||||
}
|
||||
|
||||
/// The primary's listener on either transport.
|
||||
enum Listener {
|
||||
Tcp(TcpListener),
|
||||
#[cfg(unix)]
|
||||
Unix(UnixListener),
|
||||
}
|
||||
|
||||
impl Listener {
|
||||
fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> {
|
||||
match transport {
|
||||
Transport::Loopback => {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
|
||||
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
|
||||
Ok((Listener::Tcp(listener), Endpoint::Port(port)))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
Transport::Socket(path) => {
|
||||
let at = |e: std::io::Error| format!("{}: {e}", path.display());
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(at)?;
|
||||
}
|
||||
// This process holds the lock, so a file already there is a crashed primary's.
|
||||
let _ = std::fs::remove_file(path);
|
||||
let listener = UnixListener::bind(path).map_err(at)?;
|
||||
// The runtime directory is the user's alone already; keep the socket so too.
|
||||
std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?;
|
||||
Ok((Listener::Unix(listener), Endpoint::Socket(path.clone())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&self) -> std::io::Result<Stream> {
|
||||
match self {
|
||||
Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)),
|
||||
#[cfg(unix)]
|
||||
Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection, on either transport.
|
||||
enum Stream {
|
||||
Tcp(TcpStream),
|
||||
#[cfg(unix)]
|
||||
Unix(UnixStream),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate).
|
||||
fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result<Stream> {
|
||||
match endpoint {
|
||||
Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp),
|
||||
#[cfg(unix)]
|
||||
Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_read_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.set_read_timeout(dur),
|
||||
#[cfg(unix)]
|
||||
Stream::Unix(s) => s.set_read_timeout(dur),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_write_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.set_write_timeout(dur),
|
||||
#[cfg(unix)]
|
||||
Stream::Unix(s) => s.set_write_timeout(dur),
|
||||
}
|
||||
}
|
||||
|
||||
/// One read through a shared reference (both socket types read through `&self`).
|
||||
fn read_once(&self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Tcp(s) => (&*s).read(buf),
|
||||
#[cfg(unix)]
|
||||
Stream::Unix(s) => (&*s).read(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.write(buf),
|
||||
#[cfg(unix)]
|
||||
Stream::Unix(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.flush(),
|
||||
#[cfg(unix)]
|
||||
Stream::Unix(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
|
||||
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
|
||||
fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) {
|
||||
let taken = match read_request(&stream, timing.request) {
|
||||
Some(line) => match parse_request(&line, token) {
|
||||
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
|
||||
@@ -308,7 +479,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing)
|
||||
|
||||
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
|
||||
struct Deadline<'a> {
|
||||
stream: &'a TcpStream,
|
||||
stream: &'a Stream,
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
@@ -317,25 +488,30 @@ impl Read for Deadline<'_> {
|
||||
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
|
||||
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
|
||||
self.stream.set_read_timeout(Some(left))?;
|
||||
let mut stream = self.stream;
|
||||
stream.read(buf)
|
||||
self.stream.read_once(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// The request line, if it arrives complete within `total`.
|
||||
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
|
||||
fn read_request(stream: &Stream, total: Duration) -> Option<String> {
|
||||
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
|
||||
let mut line = String::new();
|
||||
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
|
||||
}
|
||||
|
||||
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
|
||||
/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it.
|
||||
/// On Unix the file is the user's alone (0600): another local user who read the token could make
|
||||
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
|
||||
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
|
||||
fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> {
|
||||
let path = dir.join(INFO_FILE);
|
||||
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
|
||||
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
|
||||
let mut body = json!({"token": token, "pid": std::process::id()});
|
||||
match endpoint {
|
||||
Endpoint::Port(port) => body["port"] = json!(port),
|
||||
#[cfg(unix)]
|
||||
Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()),
|
||||
}
|
||||
let body = body.to_string();
|
||||
// A leftover from a crash could carry wider permissions; the mode applies only on create.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut options = OpenOptions::new();
|
||||
@@ -349,10 +525,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_info(dir: &Path) -> Option<(u16, String)> {
|
||||
fn read_info(dir: &Path) -> Option<(Endpoint, String)> {
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
|
||||
let token = v.get("token")?.as_str()?.to_string();
|
||||
#[cfg(unix)]
|
||||
if let Some(socket) = v.get("socket").and_then(Value::as_str) {
|
||||
return Some((Endpoint::Socket(PathBuf::from(socket)), token));
|
||||
}
|
||||
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
|
||||
Some((port, v.get("token")?.as_str()?.to_string()))
|
||||
Some((Endpoint::Port(port), token))
|
||||
}
|
||||
|
||||
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
|
||||
@@ -370,11 +551,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
|
||||
Some((paths, wait))
|
||||
}
|
||||
|
||||
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
|
||||
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
|
||||
/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them.
|
||||
fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
|
||||
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
|
||||
let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?;
|
||||
// The primary must answer before our deadline, so it waits a little less than we do.
|
||||
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
|
||||
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
|
||||
@@ -435,6 +615,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104).
|
||||
#[cfg(unix)]
|
||||
fn short_socket_path(tag: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE)
|
||||
}
|
||||
|
||||
/// The loopback port the primary under `dir` listens on.
|
||||
fn port_of(dir: &Path) -> u16 {
|
||||
match read_info(dir).unwrap().0 {
|
||||
Endpoint::Port(port) => port,
|
||||
#[cfg(unix)]
|
||||
Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_launch_hands_its_paths_to_the_first() {
|
||||
let dir = temp_dir("handoff");
|
||||
@@ -496,7 +691,7 @@ mod tests {
|
||||
// instance.json from a process that is gone; the lock is free.
|
||||
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
|
||||
let primary = primary(&dir);
|
||||
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
|
||||
assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1)));
|
||||
drop(primary);
|
||||
// Released with the handle, as on process exit.
|
||||
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
|
||||
@@ -516,10 +711,10 @@ mod tests {
|
||||
primary.shutdown();
|
||||
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
|
||||
// After shutdown new connections are refused too.
|
||||
let (port, token) = read_info(&dir).unwrap();
|
||||
let (endpoint, token) = read_info(&dir).unwrap();
|
||||
drop(primary);
|
||||
let short = Instant::now() + Duration::from_millis(300);
|
||||
assert!(send(port, &token, &["b.psd".into()], short).is_err());
|
||||
assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -557,7 +752,7 @@ mod tests {
|
||||
let dir = temp_dir("stalled");
|
||||
let primary = primary(&dir);
|
||||
let mut ui = primary.connect(&egui::Context::default());
|
||||
let (port, _) = read_info(&dir).unwrap();
|
||||
let port = port_of(&dir);
|
||||
// Connects and never sends a line (each would otherwise hold the listener).
|
||||
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
|
||||
let started = Instant::now();
|
||||
@@ -575,7 +770,7 @@ mod tests {
|
||||
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
|
||||
let dir = temp_dir("trickle");
|
||||
let primary = primary(&dir);
|
||||
let (port, _) = read_info(&dir).unwrap();
|
||||
let port = port_of(&dir);
|
||||
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let started = Instant::now();
|
||||
let mut cut_off = false;
|
||||
@@ -602,7 +797,7 @@ mod tests {
|
||||
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, "{}").unwrap();
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
|
||||
write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap();
|
||||
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -616,7 +811,7 @@ mod tests {
|
||||
let held = File::create(dir.join(LOCK_FILE)).unwrap();
|
||||
held.lock().unwrap();
|
||||
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
|
||||
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
|
||||
write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap();
|
||||
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
|
||||
drop(held);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -645,4 +840,79 @@ mod tests {
|
||||
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
|
||||
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
|
||||
}
|
||||
|
||||
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
|
||||
/// directory, which every sandbox of the app shares, with the same lock, token and reply.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = temp_dir("socket");
|
||||
// Short: a Unix socket path is limited to about 100 bytes (macOS: 104).
|
||||
let socket = short_socket_path("a");
|
||||
let transport = Transport::Socket(socket.clone());
|
||||
let primary = match claim_in(&dir, &[], FAST, &transport) {
|
||||
Launch::Primary(p) => p,
|
||||
_ => panic!("first launch should be primary"),
|
||||
};
|
||||
assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap();
|
||||
assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap()));
|
||||
assert!(info.get("port").is_none());
|
||||
let mut ui = primary.connect(&egui::Context::default());
|
||||
let paths = ["/home/a/one.psd".to_string()];
|
||||
let (d, p, t) = (dir.clone(), paths.clone(), transport.clone());
|
||||
let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t));
|
||||
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]);
|
||||
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
|
||||
// The token still gates the socket.
|
||||
let (endpoint, _) = read_info(&dir).unwrap();
|
||||
assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err());
|
||||
primary.shutdown();
|
||||
assert!(!socket.exists(), "shutdown removes the socket file");
|
||||
// The next primary binds the same path; the old one's drop (a second shutdown) must leave it.
|
||||
let next = match claim_in(&dir, &[], FAST, &transport) {
|
||||
Launch::Primary(p) => p,
|
||||
_ => panic!("the lock is free again"),
|
||||
};
|
||||
drop(primary);
|
||||
assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket");
|
||||
drop(next);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
let dir = temp_dir("stale-socket");
|
||||
let socket = short_socket_path("b");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(socket.parent().unwrap()).unwrap();
|
||||
std::fs::write(&socket, b"not a socket").unwrap();
|
||||
write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap();
|
||||
let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) {
|
||||
Launch::Primary(p) => p,
|
||||
Launch::Forwarded => panic!("nothing was listening"),
|
||||
Launch::Standalone(e) => panic!("{e}"),
|
||||
};
|
||||
assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket());
|
||||
drop(primary);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(socket.parent().unwrap());
|
||||
}
|
||||
|
||||
/// The socket transport is chosen only inside a Flatpak with a runtime directory.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() {
|
||||
use std::ffi::OsStr;
|
||||
let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000"));
|
||||
assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock")));
|
||||
assert_eq!(sandbox_socket(None, Some(run)), None);
|
||||
assert_eq!(sandbox_socket(Some(id), None), None);
|
||||
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
|
||||
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Slice Select tool glyph (not from an icon set): the Slice knife with a pointer. -->
|
||||
<path d="M10 14l4-4" />
|
||||
<path d="M14 10l5.6-5.6a2 2 0 0 1 2.8 2.8L16.5 12.5z" />
|
||||
<path d="M4 12v9l2.5-2.5 2 3.5 1.7-1-2-3.5H12z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -33,6 +33,8 @@ fn main() {
|
||||
depth_map: None,
|
||||
},
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
|
||||
FilterParams::SmartBlur { radius: 12.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
|
||||
FilterParams::SmartBlur { radius: 100.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
|
||||
FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Star },
|
||||
FilterParams::TiltShift { blur: 15.0, center_x: 0.5, center_y: 0.5, angle: 0.0, focus: 0.1, transition: 0.15 },
|
||||
FilterParams::IrisBlur { pins: vec![IrisPin::default()] },
|
||||
@@ -60,7 +62,12 @@ fn main() {
|
||||
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
|
||||
let t = std::time::Instant::now();
|
||||
let out = apply(&s, &p, area, r, None);
|
||||
println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed());
|
||||
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
|
||||
let name = match p {
|
||||
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
|
||||
_ => p.label().to_string(),
|
||||
};
|
||||
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
|
||||
drop(out);
|
||||
}
|
||||
}
|
||||
+140
-25
@@ -437,7 +437,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
F::Chrome => gs(1.0 + g("smoothness") * 0.4) + 2,
|
||||
F::ConteCrayon => 0,
|
||||
F::GraphicPen => (g("strokeLength") / 2.0) as i32 + 2,
|
||||
F::HalftonePattern => gs(0.5 + g("size") * 0.3),
|
||||
F::HalftonePattern => gs(halftone_cell(e) * 0.15),
|
||||
F::NotePaper => gs(1.0) + gs(1.2) + 2,
|
||||
F::Photocopy => gs(1.0 + g("detail") * 0.5),
|
||||
F::Plaster => gs(0.5 + g("smoothness") * 0.8) + gs(1.5) + 2,
|
||||
@@ -454,6 +454,55 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
fn cutout_sigma(e: &GalleryEffect) -> f32 {
|
||||
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
|
||||
}
|
||||
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
|
||||
fn halftone_cell(e: &GalleryEffect) -> f32 {
|
||||
1.5 + 2.0 * e.get("size").clamp(1.0, 12.0)
|
||||
}
|
||||
/// The round-dot spot function `(cos 2πu + cos 2πv) / 2` sampled over a cell and sorted: a
|
||||
/// value's rank among them is the share of the cell below it, which makes the thresholds even.
|
||||
fn halftone_dot_ranks() -> &'static [f32] {
|
||||
static RANKS: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
|
||||
RANKS.get_or_init(|| {
|
||||
const N: usize = 128;
|
||||
let mut v: Vec<f32> = (0..N * N)
|
||||
.map(|i| {
|
||||
let (u, w) = (((i % N) as f32 + 0.5) / N as f32, ((i / N) as f32 + 0.5) / N as f32);
|
||||
((2.0 * PI * u).cos() + (2.0 * PI * w).cos()) / 2.0
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
v
|
||||
})
|
||||
}
|
||||
/// For each tone (0 = paper … 1 = solid ink, 256 steps), the level `τ` at which the soft
|
||||
/// threshold `clamp(0.5 + (τ − s) · gain)` over evenly spread thresholds `s` inks exactly that
|
||||
/// share of a cell on average.
|
||||
fn halftone_offsets(gain: f32) -> Vec<f32> {
|
||||
let half = 0.5 / gain.max(1e-3);
|
||||
// Average ink over s in [0, 1] for level τ: 1 below τ − half, a ramp to 0 at τ + half.
|
||||
let cover = |tau: f32| {
|
||||
let (a, z) = (tau - half, tau + half);
|
||||
let solid = a.clamp(0.0, 1.0);
|
||||
let (r0, r1) = (a.clamp(0.0, 1.0), z.clamp(0.0, 1.0));
|
||||
let ramp = if r1 > r0 { (0.5 + tau * gain) * (r1 - r0) - 0.5 * gain * (r1 * r1 - r0 * r0) } else { 0.0 };
|
||||
solid + ramp
|
||||
};
|
||||
(0..256)
|
||||
.map(|i| {
|
||||
let target = i as f32 / 255.0;
|
||||
let (mut lo, mut hi) = (-half - 1.0, 1.0 + half + 1.0);
|
||||
for _ in 0..40 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if cover(mid) < target { lo = mid } else { hi = mid }
|
||||
}
|
||||
match i {
|
||||
0 => -half - 1.0,
|
||||
255 => 1.0 + half + 1.0,
|
||||
_ => (lo + hi) / 2.0,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn daubs_radius(e: &GalleryEffect) -> usize {
|
||||
let wide = matches!(e.choice("brushType"), "wideSharp" | "wideBlurry");
|
||||
let r = e.get("brushSize") / 2.0 * if wide { 1.5 } else { 1.0 };
|
||||
@@ -506,6 +555,7 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
|
||||
res
|
||||
}
|
||||
|
||||
/// Applies one gallery effect to the working window in place.
|
||||
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
let g = |k: &str| e.get(k);
|
||||
let (w, h) = (win.w, win.h);
|
||||
@@ -933,36 +983,46 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
}
|
||||
}
|
||||
F::HalftonePattern => {
|
||||
let size = g("size");
|
||||
let con = g("contrast") / 50.0;
|
||||
let cell = 3.0 + size * 2.0;
|
||||
// A halftone screen that keeps a continuous range of tones: each pixel compares its
|
||||
// ink (the darkness of the image) with the screen's threshold there, and Contrast is
|
||||
// how steeply: at 0 the image keeps its tones with the screen faintly through it, at
|
||||
// 50 it is crisp dots, lines or rings. The thresholds are spread evenly over each cell,
|
||||
// and each tone is offset so a cell's average ink is exactly that tone: black stays
|
||||
// solid and white stays paper at any Contrast.
|
||||
let cell = halftone_cell(e);
|
||||
let gain = 0.5 + g("contrast") * 0.6;
|
||||
let offsets = halftone_offsets(gain);
|
||||
let mut l = win.lum();
|
||||
blur1(&mut l, w, h, 0.5 + size * 0.3);
|
||||
blur1(&mut l, w, h, cell * 0.15);
|
||||
let b = ctx.bounds;
|
||||
let (cx, cy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
|
||||
let ty = e.choice("patternType");
|
||||
let dots = halftone_dot_ranks();
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let d = clamp01((1.0 - l[i] - 0.5) * (1.0 + con * 3.0) + 0.5);
|
||||
let aa = 1.0 / cell;
|
||||
let ink = match ty {
|
||||
"circle" => {
|
||||
let r = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt() / cell;
|
||||
let q = (r.fract() - 0.5).abs() * 2.0;
|
||||
smoothstep(d + aa, d - aa, q)
|
||||
}
|
||||
"line" => {
|
||||
let q = ((fy / cell).fract() - 0.5).abs() * 2.0;
|
||||
smoothstep(d + aa, d - aa, q)
|
||||
}
|
||||
let band = |t: f32| ((t / cell).rem_euclid(1.0) - 0.5).abs() * 2.0;
|
||||
let spot = match ty {
|
||||
// Rings round the middle of the image, inked from the middle of each ring.
|
||||
"circle" => band(((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt()),
|
||||
// Horizontal lines, inked from the middle of each.
|
||||
"line" => band(fy),
|
||||
// Round dots on a screen at 45° (as a black halftone screen is), that grow
|
||||
// from each cell's centre, meet at 50% as a checkerboard of squares square to
|
||||
// the image, and fill the cell at 100%.
|
||||
_ => {
|
||||
let (u, v) = ((fx / cell).fract() - 0.5, (fy / cell).fract() - 0.5);
|
||||
let q = (u * u + v * v).sqrt() * 2.0;
|
||||
let rr = d.sqrt() * 1.15;
|
||||
smoothstep(rr + aa, rr - aa, q)
|
||||
let (rx, ry) = ((fx + fy) * std::f32::consts::FRAC_1_SQRT_2, (fy - fx) * std::f32::consts::FRAC_1_SQRT_2);
|
||||
let (u, v) = ((rx / cell).rem_euclid(1.0), (ry / cell).rem_euclid(1.0));
|
||||
let f = ((2.0 * PI * u).cos() + (2.0 * PI * v).cos()) / 2.0;
|
||||
let k = dots.partition_point(|&r| r < f);
|
||||
k as f32 / dots.len().max(1) as f32
|
||||
}
|
||||
};
|
||||
let tone = clamp01(1.0 - l[i]) * (offsets.len() - 1) as f32;
|
||||
let (k, f) = (tone.floor() as usize, tone.fract());
|
||||
let at = |j: usize| offsets.get(j.min(offsets.len() - 1)).copied().unwrap_or(0.5);
|
||||
let level = at(k) + (at(k + 1) - at(k)) * f;
|
||||
let ink = clamp01(0.5 + (level - spot) * gain);
|
||||
win.c[i] = mix(bg, fg, ink);
|
||||
}
|
||||
}
|
||||
@@ -1254,6 +1314,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
|
||||
/// solid, and Contrast makes the screen crisper.
|
||||
#[test]
|
||||
fn halftone_keeps_tones_and_contrast_sets_how_hard() {
|
||||
// It used to be all ink or paper at any Contrast, with white crosses in the shadows
|
||||
// (dots could never fill their cell) and mid-greys that came out the wrong darkness.
|
||||
use crate::GalleryFilter as G;
|
||||
let b = Rect::new(0, 0, 60, 60);
|
||||
let run = |grey: f32, pattern: &str, contrast: f32| {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
|
||||
s.fill_rect(b, &[grey, grey, grey, 1.0]);
|
||||
let mut e = GalleryEffect::new(G::HalftonePattern);
|
||||
assert!(e.set_choice("patternType", pattern));
|
||||
e.set("size", 4.0);
|
||||
e.set("contrast", contrast);
|
||||
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
|
||||
// Ink (1 = foreground black) of every pixel away from the canvas edge.
|
||||
out.chunks(4).enumerate().filter(|(i, _)| (6..54).contains(&(i % 60)) && (6..54).contains(&(i / 60))).map(|(_, p)| 1.0 - p[0]).collect::<Vec<f32>>()
|
||||
};
|
||||
for pattern in ["dot", "circle", "line"] {
|
||||
for contrast in [0.0, 5.0, 50.0] {
|
||||
for grey in [0.2, 0.5, 0.8] {
|
||||
let ink = run(grey, pattern, contrast);
|
||||
let mean = ink.iter().sum::<f32>() / ink.len() as f32;
|
||||
assert!((mean - (1.0 - grey)).abs() < 0.06, "{pattern} contrast {contrast}: grey {grey} inks {mean}");
|
||||
}
|
||||
let solid = run(0.0, pattern, contrast);
|
||||
assert!(solid.iter().all(|&v| v > 0.99), "{pattern} contrast {contrast}: black isn't solid");
|
||||
let paper = run(1.0, pattern, contrast);
|
||||
assert!(paper.iter().all(|&v| v < 0.01), "{pattern} contrast {contrast}: white isn't paper");
|
||||
}
|
||||
let soft = run(0.5, pattern, 0.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
|
||||
let hard = run(0.5, pattern, 50.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
|
||||
assert!(hard * 4 < soft, "{pattern}: contrast 50 should be much crisper ({hard} vs {soft} grey pixels)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parse_and_clamp() {
|
||||
for f in crate::GalleryFilter::ALL {
|
||||
@@ -1305,7 +1402,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Visual check: `GALLERY_RAW=src.raw GALLERY_W=480 GALLERY_H=322 cargo test -p photocraft-algo --release gallery_sheet -- --ignored`
|
||||
/// writes `<key>.raw` (RGB8) beside the input for every filter.
|
||||
/// writes `<key>.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn gallery_sheet() {
|
||||
@@ -1318,11 +1415,29 @@ mod tests {
|
||||
let b = Rect::new(0, 0, w, h);
|
||||
s.write_region(b, &data);
|
||||
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
|
||||
for f in crate::GalleryFilter::ALL {
|
||||
let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] };
|
||||
// Optional: GALLERY_ONLY=<key> renders one filter; GALLERY_SET="key=value,key=name" sets its
|
||||
// sliders (numbers) or choices (names); GALLERY_TAG is appended to the file name.
|
||||
let only = std::env::var("GALLERY_ONLY").ok();
|
||||
let set = std::env::var("GALLERY_SET").unwrap_or_default();
|
||||
let tag = std::env::var("GALLERY_TAG").unwrap_or_default();
|
||||
if let Some(k) = &only {
|
||||
assert!(crate::GalleryFilter::ALL.iter().any(|f| f.key() == k), "GALLERY_ONLY={k} is not a gallery filter");
|
||||
}
|
||||
for f in crate::GalleryFilter::ALL.iter().filter(|f| only.as_deref().is_none_or(|k| k == f.key())) {
|
||||
let mut e = GalleryEffect::new(*f);
|
||||
for kv in set.split(',').filter(|kv| !kv.is_empty()) {
|
||||
let (k, v) = kv.split_once('=').unwrap();
|
||||
// `set` ignores unknown keys; a misspelt one would render the default unnoticed.
|
||||
assert!(f.params().iter().any(|p| p.key == k), "{k} is not a parameter of {}", f.key());
|
||||
match v.parse::<f32>() {
|
||||
Ok(n) => e.set(k, n),
|
||||
Err(_) => assert!(e.set_choice(k, v), "{k} has no choice {v}"),
|
||||
}
|
||||
}
|
||||
let fp = FilterParams::FilterGallery { effects: vec![e] };
|
||||
let out = crate::apply_in(&s, &fp, b, b, None, b).read_region(b);
|
||||
let raw: Vec<u8> = out.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
|
||||
std::fs::write(dir.join(format!("{}.raw", f.key())), raw).unwrap();
|
||||
std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-21
@@ -189,6 +189,11 @@ mod shape_tests;
|
||||
/// Smart Blur: averages only neighbours within `threshold` levels of the
|
||||
/// centre pixel (so edges stay crisp); Edge Only / Overlay Edge draw the
|
||||
/// edges (where neighbours differ by more than the threshold) in white.
|
||||
///
|
||||
/// Neighbours outside `src` read as zero, as [`Image::get`] does. Pixels whose whole
|
||||
/// kernel lies inside `src` (all of them, with the usual halo) read neighbours at
|
||||
/// precomputed offsets into the window instead of by coordinates, and for up to five
|
||||
/// channels go through [`smart_rows`] (#7921).
|
||||
pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = ncol(ctx, n);
|
||||
@@ -201,31 +206,48 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
|
||||
BlurQuality::High => 12,
|
||||
};
|
||||
let stride = ((ri + target - 1) / target).max(1);
|
||||
let offs: Vec<(i32, i32)> = (-ri..=ri)
|
||||
let win = src.rect;
|
||||
let ww = win.width() as isize;
|
||||
// Each offset with its distance in samples within the window.
|
||||
let offs: Vec<(i32, i32, isize)> = (-ri..=ri)
|
||||
.step_by(stride as usize)
|
||||
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
|
||||
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
|
||||
.map(|(dx, dy)| (dx, dy, (dy as isize * ww + dx as isize) * n as isize))
|
||||
.collect();
|
||||
let at = |x: i32, y: i32| win.contains(x, y).then(|| ((y - win.y0) as usize * ww as usize + (x - win.x0) as usize) * n);
|
||||
let zeros = [0.0f32; MAXC];
|
||||
let data = &src.data[..];
|
||||
// The pixel at sample `i`, or zeros outside the window.
|
||||
let sample = |i: Option<usize>| i.and_then(|i| data.get(i..i + n)).unwrap_or(&zeros[..n]);
|
||||
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
|
||||
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
|
||||
let mut res = src.crop(out);
|
||||
let mut q = [0.0f32; MAXC];
|
||||
let inner = Rect::new(win.x0.saturating_add(ri), win.y0.saturating_add(ri), win.x1.saturating_sub(ri), win.y1.saturating_sub(ri)).intersect(&out);
|
||||
let lin: Vec<isize> = offs.iter().map(|o| o.2).collect();
|
||||
let rows = match (mode, n, ctx.alpha) {
|
||||
(SmartBlurMode::Normal, 1, false) => smart_rows::<1, false>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 1, true) => smart_rows::<1, true>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 2, false) => smart_rows::<2, false>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 2, true) => smart_rows::<2, true>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 3, false) => smart_rows::<3, false>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 3, true) => smart_rows::<3, true>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 4, false) => smart_rows::<4, false>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 4, true) => smart_rows::<4, true>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 5, false) => smart_rows::<5, false>(src, out, inner, &lin, t, &mut res),
|
||||
(SmartBlurMode::Normal, 5, true) => smart_rows::<5, true>(src, out, inner, &lin, t, &mut res),
|
||||
_ => Rect::EMPTY,
|
||||
};
|
||||
let mut acc = [0.0f32; MAXC];
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
if rows.contains(x, y) {
|
||||
continue;
|
||||
}
|
||||
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
|
||||
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
|
||||
let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).abs() > t);
|
||||
if mode != SmartBlurMode::Normal {
|
||||
let mut edge = false;
|
||||
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
|
||||
for (c, v) in q.iter_mut().enumerate().take(n) {
|
||||
*v = src.get(x + dx, y + dy, c);
|
||||
}
|
||||
if differs(&q[..n]) {
|
||||
edge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy))));
|
||||
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
|
||||
if edge {
|
||||
px[..cc].copy_from_slice(&white[..cc]);
|
||||
@@ -237,18 +259,28 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let interior = x - ri >= win.x0 && x + ri < win.x1 && y - ri >= win.y0 && y + ri < win.y1;
|
||||
let centre = if interior { at(x, y) } else { None };
|
||||
acc[..n].fill(0.0);
|
||||
let mut wsum = 0.0f32;
|
||||
for &(dx, dy) in &offs {
|
||||
for (c, v) in q.iter_mut().enumerate().take(n) {
|
||||
*v = src.get(x + dx, y + dy, c);
|
||||
}
|
||||
if differs(&q[..n]) {
|
||||
for &(dx, dy, d) in &offs {
|
||||
let q = sample(match centre {
|
||||
Some(c) => c.checked_add_signed(d),
|
||||
None => at(x + dx, y + dy),
|
||||
});
|
||||
if differs(q) {
|
||||
continue;
|
||||
}
|
||||
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
|
||||
for c in 0..n {
|
||||
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
|
||||
if ctx.alpha {
|
||||
let a = q[n - 1];
|
||||
for (s, v) in acc.iter_mut().zip(&q[..cc]) {
|
||||
*s += v * a;
|
||||
}
|
||||
acc[n - 1] += a;
|
||||
} else {
|
||||
for (s, v) in acc.iter_mut().zip(q) {
|
||||
*s += v;
|
||||
}
|
||||
}
|
||||
wsum += 1.0;
|
||||
}
|
||||
@@ -262,6 +294,77 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
|
||||
res
|
||||
}
|
||||
|
||||
/// Smart Blur's normal mode over `inner` (pixels whose whole kernel lies in `src`) for `N`
|
||||
/// channels, the last alpha if `A`; `lin` are the neighbours as sample offsets in the window.
|
||||
/// Each offset sweeps a whole row, so neighbours load contiguously and a rejected one adds
|
||||
/// `-0.0` (an exact identity) instead of branching: every pixel sums its neighbours in the same
|
||||
/// order as [`smart_blur`]'s per-pixel loop, giving the same bits. Returns the area written: the
|
||||
/// rows finished, empty if the layout doesn't match.
|
||||
fn smart_rows<const N: usize, const A: bool>(src: &Image, out: Rect, inner: Rect, lin: &[isize], t: f32, res: &mut [f32]) -> Rect {
|
||||
let win = src.rect;
|
||||
if src.ch != N || inner.is_empty() || !win.contains_rect(&inner) || !out.contains_rect(&inner) {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let cc = if A { N - 1 } else { N };
|
||||
let (ww, ow, len) = (win.width() as usize, out.width() as usize, inner.width() as usize);
|
||||
let pixels = len * N;
|
||||
let mut acc = vec![[0.0f32; N]; len];
|
||||
let mut wsum = vec![0.0f32; len];
|
||||
// Rows before `y` are done; a row is written only once all its neighbours were read.
|
||||
let done = |y: i32| Rect::new(inner.x0, inner.y0, inner.x1, y);
|
||||
for y in inner.y0..inner.y1 {
|
||||
let centre = ((y - win.y0) as usize * ww + (inner.x0 - win.x0) as usize) * N;
|
||||
let Some(me) = src.data.get(centre..centre + pixels) else {
|
||||
return done(y);
|
||||
};
|
||||
let me = me.as_chunks::<N>().0;
|
||||
acc.fill([0.0; N]);
|
||||
wsum.fill(0.0);
|
||||
for &d in lin {
|
||||
let Some(q) = centre.checked_add_signed(d).and_then(|i| src.data.get(i..i + pixels)) else {
|
||||
return done(y);
|
||||
};
|
||||
for (((s, w), q), m) in acc.iter_mut().zip(wsum.iter_mut()).zip(q.as_chunks::<N>().0).zip(me) {
|
||||
// As `differs`: a NaN difference is not over the threshold.
|
||||
let mut far = false;
|
||||
for c in 0..cc {
|
||||
far |= (q[c] - m[c]).abs() > t;
|
||||
}
|
||||
let keep = !far;
|
||||
if A {
|
||||
let a = q[N - 1];
|
||||
for c in 0..N - 1 {
|
||||
s[c] += if keep { q[c] * a } else { -0.0 };
|
||||
}
|
||||
s[N - 1] += if keep { a } else { -0.0 };
|
||||
} else {
|
||||
for c in 0..N {
|
||||
s[c] += if keep { q[c] } else { -0.0 };
|
||||
}
|
||||
}
|
||||
*w += if keep { 1.0 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
let o = ((y - out.y0) as usize * ow + (inner.x0 - out.x0) as usize) * N;
|
||||
let Some(dst) = res.get_mut(o..o + pixels) else {
|
||||
return done(y);
|
||||
};
|
||||
for ((px, s), &w) in dst.as_chunks_mut::<N>().0.iter_mut().zip(&acc).zip(&wsum) {
|
||||
if w > 0.0 {
|
||||
for c in 0..N {
|
||||
px[c] = s[c] / w;
|
||||
}
|
||||
unpremul_px(px, A);
|
||||
}
|
||||
}
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "smart_blur_tests.rs"]
|
||||
mod smart_tests;
|
||||
|
||||
// ---------- lens blur ----------
|
||||
|
||||
pub(crate) struct LensSpec<'a> {
|
||||
|
||||
+19
-2
@@ -411,6 +411,14 @@ pub enum FilterParams {
|
||||
},
|
||||
|
||||
// ---- Render ----
|
||||
/// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight
|
||||
/// sRGB RGBA colours; the cloud size follows the bounds.
|
||||
Clouds {
|
||||
seed: u32,
|
||||
difference: bool,
|
||||
foreground: [f32; 4],
|
||||
background: [f32; 4],
|
||||
},
|
||||
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
|
||||
Fibers {
|
||||
variance: f32,
|
||||
@@ -606,7 +614,13 @@ impl FilterParams {
|
||||
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
|
||||
/// empty layer, while still reading no neighbourhood.
|
||||
pub fn renders_from_nothing(&self) -> bool {
|
||||
matches!(self, FilterParams::Fibers { .. })
|
||||
matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. })
|
||||
}
|
||||
|
||||
/// Whether the filter places and sizes its pattern by the canvas even with a selection
|
||||
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
|
||||
pub fn sizes_by_canvas(&self) -> bool {
|
||||
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
|
||||
}
|
||||
|
||||
/// Human-readable name.
|
||||
@@ -653,6 +667,8 @@ impl FilterParams {
|
||||
FilterParams::Displace { .. } => "Displace",
|
||||
FilterParams::Shear { .. } => "Shear",
|
||||
FilterParams::ZigZag { .. } => "ZigZag",
|
||||
FilterParams::Clouds { difference: false, .. } => "Clouds",
|
||||
FilterParams::Clouds { difference: true, .. } => "Difference Clouds",
|
||||
FilterParams::Fibers { .. } => "Fibers",
|
||||
FilterParams::LensFlare { .. } => "Lens Flare",
|
||||
FilterParams::LightingEffects { .. } => "Lighting Effects",
|
||||
@@ -697,7 +713,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
|
||||
FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)),
|
||||
FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds,
|
||||
// Generators and per-pixel renders only need the bounds geometry (always in the context).
|
||||
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
|
||||
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0),
|
||||
FilterParams::LightingEffects { .. } => r(1.0),
|
||||
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
|
||||
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
|
||||
@@ -817,6 +833,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
}
|
||||
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
|
||||
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
|
||||
FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)),
|
||||
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
|
||||
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
|
||||
}
|
||||
|
||||
@@ -654,6 +654,7 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
|
||||
return out;
|
||||
}
|
||||
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
|
||||
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
|
||||
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
|
||||
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
|
||||
let w = t.width() as usize;
|
||||
@@ -670,7 +671,15 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
|
||||
let px = f64::from(t.x0) + x as f64 + 0.5;
|
||||
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
|
||||
let wx = catmull_rom(gx - gx.floor());
|
||||
let d = field.sample_cubic(gx, gy, &wx, &wy);
|
||||
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
|
||||
if has_freeze {
|
||||
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
|
||||
if mask > 0.0 {
|
||||
let linear = field.sample(px, py);
|
||||
d[0] = d[0] * (1.0 - mask) + linear[0] * mask;
|
||||
d[1] = d[1] * (1.0 - mask) + linear[1] * mask;
|
||||
}
|
||||
}
|
||||
if d[0] != 0.0 || d[1] != 0.0 {
|
||||
moved = true;
|
||||
}
|
||||
@@ -1376,6 +1385,42 @@ mod tests {
|
||||
assert!(g.freeze.iter().all(|f| *f == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_frozen_pixels_keep_their_mapping_when_an_adjacent_area_is_warped() {
|
||||
let bounds = bounds();
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
|
||||
for y in 0..bounds.height() {
|
||||
for x in 0..bounds.width() {
|
||||
src.write_pixel(x as i32, y as i32, &[x as f32 / bounds.width() as f32, y as f32 / bounds.height() as f32, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut mask_only = LiquifyField::new(bounds, 2.0);
|
||||
mask_only.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
|
||||
assert_eq!(apply_liquify(&src, &mask_only), src);
|
||||
|
||||
let mut field = LiquifyField::new(bounds, 2.0);
|
||||
let mut first_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
|
||||
first_warp.points = vec![vec![22.0, 32.0], vec![34.0, 32.0]];
|
||||
field.apply_stroke(&first_warp);
|
||||
|
||||
field.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
|
||||
let frozen_pixel = (37, 32);
|
||||
assert!(field.freeze_at(f64::from(frozen_pixel.0) + 0.5, f64::from(frozen_pixel.1) + 0.5) > 0.999);
|
||||
let before = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
|
||||
assert_ne!(before, src.pixel(frozen_pixel.0, frozen_pixel.1), "freezing must retain the earlier warp");
|
||||
let neighbor_before = apply_liquify(&src, &field).pixel(43, 32);
|
||||
|
||||
let mut adjacent_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 24.0);
|
||||
adjacent_warp.points = vec![vec![44.0, 32.0], vec![52.0, 32.0]];
|
||||
field.apply_stroke(&adjacent_warp);
|
||||
|
||||
let after = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
|
||||
let neighbor_after = apply_liquify(&src, &field).pixel(43, 32);
|
||||
assert_eq!(before, after, "a fully frozen pixel must keep its pre-neighbor-warp mapping");
|
||||
assert_ne!(neighbor_before, neighbor_after, "an adjacent unfrozen pixel must still move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twirl_pucker_bloat_and_push_left_have_the_right_sense() {
|
||||
let at = |tool: LiquifyTool, pts: Vec<Vec<f64>>| {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Render: Fibers, Lens Flare, Lighting Effects.
|
||||
//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
@@ -18,6 +18,57 @@ fn value_noise(x: f32, y: f32, seed: u32) -> f32 {
|
||||
top + (bot - top) * sy
|
||||
}
|
||||
|
||||
fn cloud_hash(x: i32, y: i32, seed: u32) -> f32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0x5bd1_e995);
|
||||
h ^= h >> 15;
|
||||
(h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32
|
||||
}
|
||||
|
||||
/// Smooth value noise, summed over octaves (fBm), in 0..1.
|
||||
fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 {
|
||||
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0);
|
||||
for o in 0..8u32 {
|
||||
let (fx, fy) = (x * freq, y * freq);
|
||||
let (ix, iy) = (fx.floor() as i32, fy.floor() as i32);
|
||||
let (tx, ty) = (fx - ix as f32, fy - iy as f32);
|
||||
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
|
||||
let s = seed.wrapping_add(o * 7919);
|
||||
let top = cloud_hash(ix, iy, s) + (cloud_hash(ix + 1, iy, s) - cloud_hash(ix, iy, s)) * sx;
|
||||
let bot = cloud_hash(ix, iy + 1, s) + (cloud_hash(ix + 1, iy + 1, s) - cloud_hash(ix, iy + 1, s)) * sx;
|
||||
sum += (top + (bot - top) * sy) * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
freq *= 2.0;
|
||||
}
|
||||
// Stretch the bell-shaped sum so the clouds use the full foreground→background range.
|
||||
((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Clouds between the foreground and background colours; Difference Clouds subtracts them from
|
||||
/// the pixels (alpha kept). The cloud size is a quarter of the bounds' longer side, and the
|
||||
/// pattern is anchored at the bounds' corner (so it moves with a smart object).
|
||||
pub(crate) fn clouds(src: &Image, out: Rect, ctx: &Ctx, seed: u32, difference: bool, colours: ([f32; 4], [f32; 4])) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let (fg, bg) = colours;
|
||||
let base = (ctx.bounds.width().max(ctx.bounds.height()) as f32 / 4.0).clamp(16.0, 512.0);
|
||||
let mut res = src.crop(out);
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
let (x, y) = (x - ctx.bounds.x0, y - ctx.bounds.y0);
|
||||
let t = clouds_value(x as f32, y as f32, base, seed);
|
||||
let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t);
|
||||
if difference {
|
||||
let p = rgba(ctx, px);
|
||||
set_rgba(ctx, px, [(p[0] - c[0]).abs(), (p[1] - c[1]).abs(), (p[2] - c[2]).abs(), p[3]]);
|
||||
} else {
|
||||
set_rgba(ctx, px, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Fibers: vertical fibrous streaks between the foreground and background
|
||||
/// colours. Variance sets how quickly the colour varies across fibres (and
|
||||
/// how short they are); strength stretches each fibre vertically.
|
||||
@@ -32,8 +83,9 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength:
|
||||
let fy = fx / (2.0 + st * 1.5) * (0.5 + var / 32.0);
|
||||
let mut res = src.crop(out);
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
// Anchored at the bounds' corner, so the fibres move with a smart object.
|
||||
let (x, y) = xy(out, i);
|
||||
let (xf, yf) = (x as f32, y as f32);
|
||||
let (xf, yf) = ((x - ctx.bounds.x0) as f32, (y - ctx.bounds.y0) as f32);
|
||||
let mut sum = 0.0;
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an
|
||||
//! exact oracle: the offset fast path must give the same bits for every mode, depth and layout.
|
||||
use super::*;
|
||||
use photocraft_color::ColorMode;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn smart_blur_oracle(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = ncol(ctx, n);
|
||||
let r = radius.clamp(0.1, 100.0);
|
||||
let t = threshold.clamp(0.1, 100.0) / 255.0;
|
||||
let ri = r.ceil() as i32;
|
||||
let target = match quality {
|
||||
BlurQuality::Low => 4,
|
||||
BlurQuality::Medium => 7,
|
||||
BlurQuality::High => 12,
|
||||
};
|
||||
let stride = ((ri + target - 1) / target).max(1);
|
||||
let offs: Vec<(i32, i32)> = (-ri..=ri)
|
||||
.step_by(stride as usize)
|
||||
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
|
||||
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
|
||||
.collect();
|
||||
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
|
||||
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
|
||||
let mut res = src.crop(out);
|
||||
let mut q = [0.0f32; MAXC];
|
||||
let mut acc = [0.0f32; MAXC];
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
|
||||
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
|
||||
if mode != SmartBlurMode::Normal {
|
||||
let mut edge = false;
|
||||
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
|
||||
for (c, v) in q.iter_mut().enumerate().take(n) {
|
||||
*v = src.get(x + dx, y + dy, c);
|
||||
}
|
||||
if differs(&q[..n]) {
|
||||
edge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
|
||||
if edge {
|
||||
px[..cc].copy_from_slice(&white[..cc]);
|
||||
} else if mode == SmartBlurMode::EdgeOnly {
|
||||
px[..cc].copy_from_slice(&black[..cc]);
|
||||
}
|
||||
if ctx.alpha {
|
||||
px[n - 1] = a;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
acc[..n].fill(0.0);
|
||||
let mut wsum = 0.0f32;
|
||||
for &(dx, dy) in &offs {
|
||||
for (c, v) in q.iter_mut().enumerate().take(n) {
|
||||
*v = src.get(x + dx, y + dy, c);
|
||||
}
|
||||
if differs(&q[..n]) {
|
||||
continue;
|
||||
}
|
||||
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
|
||||
for c in 0..n {
|
||||
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
|
||||
}
|
||||
wsum += 1.0;
|
||||
}
|
||||
if wsum > 0.0 {
|
||||
for c in 0..n {
|
||||
px[c] = acc[c] / wsum;
|
||||
}
|
||||
unpremul_px(px, ctx.alpha);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Flat blocks with small noise, so some neighbours fall within the threshold and some don't.
|
||||
fn image(rect: Rect, n: usize, alpha: bool) -> Image {
|
||||
let w = rect.width() as usize;
|
||||
let data = (0..w * rect.height() as usize)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let block = ((x / 5 + y / 7) % 3) as f32 * 0.3;
|
||||
(0..n).map(move |c| {
|
||||
let noise = ((i * 31 + c * 13) % 17) as f32 / 160.0;
|
||||
if alpha && c + 1 == n { [1.0, 0.0, 0.4, 0.75][(x + y) % 4] } else { block + noise }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Image { rect, ch: n, data }
|
||||
}
|
||||
|
||||
fn exact(a: &[f32], b: &[f32], what: &str) {
|
||||
assert_eq!(a.len(), b.len(), "{what}");
|
||||
for (i, (a, b)) in a.iter().zip(b).enumerate() {
|
||||
assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "{what}: sample {i}: {a} != {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_fast_path_matches_the_per_channel_oracle() {
|
||||
let mut layouts: Vec<(ColorMode, usize, bool)> = vec![
|
||||
(ColorMode::Rgb, 3, false),
|
||||
(ColorMode::Rgb, 4, true),
|
||||
(ColorMode::Grayscale, 1, false),
|
||||
(ColorMode::Grayscale, 2, true),
|
||||
(ColorMode::Cmyk, 4, false),
|
||||
(ColorMode::Cmyk, 5, true),
|
||||
(ColorMode::Lab, 4, true),
|
||||
];
|
||||
layouts.extend((1..=MAXC).flat_map(|n| [(ColorMode::Multichannel, n, false), (ColorMode::Multichannel, n, true)]));
|
||||
let win = Rect::new(-7, -4, 33, 30);
|
||||
for (mode, n, alpha) in layouts {
|
||||
let src = image(win, n, alpha);
|
||||
let ctx = Ctx { bounds: win, mode, alpha };
|
||||
for radius in [0.1f32, 1.0, 3.0, 12.0, 17.5] {
|
||||
let ri = f32::ceil(radius) as i32;
|
||||
// The usual tile (the window less the halo), the whole window, and one sticking out of it.
|
||||
let inner = Rect::new(win.x0 + ri, win.y0 + ri, win.x1 - ri, win.y1 - ri);
|
||||
for out in [inner, win, Rect::new(-12, -9, 10, 8), Rect::EMPTY] {
|
||||
for quality in [BlurQuality::Low, BlurQuality::Medium, BlurQuality::High] {
|
||||
for sm in [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge] {
|
||||
for threshold in [8.0, 60.0] {
|
||||
let what = format!("{mode:?} n={n} alpha={alpha} r={radius} {out:?} {quality:?} {sm:?} t={threshold}");
|
||||
exact(
|
||||
&smart_blur(&src, out, &ctx, radius, threshold, quality, sm),
|
||||
&smart_blur_oracle(&src, out, &ctx, radius, threshold, quality, sm),
|
||||
&what,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_window_neighbours_read_as_zero() {
|
||||
// A one-pixel window: every neighbour is outside and reads as transparent black, so a black
|
||||
// centre averages with all of them and a white one keeps itself.
|
||||
let ctx = Ctx { bounds: Rect::new(0, 0, 1, 1), mode: ColorMode::Rgb, alpha: true };
|
||||
for v in [0.0, 1.0] {
|
||||
let src = Image { rect: Rect::new(0, 0, 1, 1), ch: 4, data: vec![v, v, v, 1.0] };
|
||||
let got = smart_blur(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal);
|
||||
exact(&got, &smart_blur_oracle(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal), "1 px");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_and_infinite_samples_match_the_oracle() {
|
||||
// A NaN difference is never over the threshold, so it is averaged in; an infinite one is.
|
||||
let win = Rect::new(0, 0, 24, 20);
|
||||
for (n, alpha) in [(3, false), (4, true)] {
|
||||
let mut src = image(win, n, alpha);
|
||||
for (i, v) in [(5, f32::NAN), (97, f32::INFINITY), (230, f32::NEG_INFINITY), (311, -0.0)] {
|
||||
let len = src.data.len();
|
||||
src.data[(i * n) % len] = v;
|
||||
}
|
||||
let ctx = Ctx { bounds: win, mode: ColorMode::Rgb, alpha };
|
||||
let out = Rect::new(4, 4, 20, 16);
|
||||
for threshold in [8.0, 100.0] {
|
||||
exact(
|
||||
&smart_blur(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
|
||||
&smart_blur_oracle(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
|
||||
&format!("n={n} t={threshold}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,21 @@ pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
|
||||
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
|
||||
}
|
||||
|
||||
/// The farthest from the origin (px) [`warp_surface`] places a pixel: each side of
|
||||
/// [`warp_output_bounds`] is clamped to ±2²⁰.
|
||||
pub const WARP_REACH: i32 = 1 << 20;
|
||||
|
||||
/// Would [`warp_surface`] clip the result of warping `src_rect` by `h`: does a warped corner land
|
||||
/// past [`WARP_REACH`], whether the result is that large or only placed that far out? A caller
|
||||
/// refuses such a warp rather than lose the pixels beyond it. Empty and singular inputs have no
|
||||
/// output to clip.
|
||||
pub fn warp_output_clipped(src_rect: Rect, h: &Homography) -> bool {
|
||||
let Some(corners) = warp_corners(src_rect, h) else { return false };
|
||||
let lim = f64::from(WARP_REACH);
|
||||
let within = |v: f64| v.is_finite() && v.floor() >= -lim && v.ceil() <= lim;
|
||||
!corners.iter().all(|&(x, y)| within(x) && within(y))
|
||||
}
|
||||
|
||||
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
|
||||
if src_rect.is_empty() || h.inverse().is_none() {
|
||||
return None;
|
||||
@@ -264,11 +279,13 @@ fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
|
||||
)
|
||||
}
|
||||
|
||||
/// The pixel rectangle over the warped `corners` plus a pixel for filter support, each side
|
||||
/// clamped to [`WARP_REACH`]; `None` when a corner is not finite.
|
||||
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
|
||||
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let lim = (1 << 20) as f64;
|
||||
let lim = f64::from(WARP_REACH);
|
||||
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
|
||||
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
|
||||
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
|
||||
@@ -294,7 +311,7 @@ fn warp(
|
||||
converted = src.try_convert(fmt)?;
|
||||
&converted
|
||||
};
|
||||
let mut out = Surface::new(fmt);
|
||||
let out = Surface::new(fmt);
|
||||
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
|
||||
return Ok(out);
|
||||
}
|
||||
@@ -366,7 +383,7 @@ fn warp(
|
||||
return Ok(None);
|
||||
}
|
||||
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes.
|
||||
// Short of 100 %: the result is still pruned and handed back after the last tile.
|
||||
ctl.progress(0.98 * k as f32 / total as f32);
|
||||
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
|
||||
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
|
||||
@@ -503,33 +520,29 @@ fn warp(
|
||||
}
|
||||
Ok(any.then_some((*t, outp)))
|
||||
};
|
||||
let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec<f32>)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
let mut done = Vec::new();
|
||||
done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?;
|
||||
let done = std::sync::Mutex::new(done);
|
||||
// Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32`
|
||||
// pixels: kept until the last one was done, they cost 16 bytes a pixel of an RGBA result on
|
||||
// top of the result's own (#7807).
|
||||
let out = std::sync::Mutex::new(out);
|
||||
let warp_and_write = |tile: &Rect| -> Result<(), photocraft_raster::AllocationError> {
|
||||
if let Some((r, v)) = work(tile)? {
|
||||
out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).try_write_region(r, &v)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> {
|
||||
if let Some(result) = work(&tile)? {
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
tiles.into_par_iter().try_for_each(|tile| warp_and_write(&tile))?;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for tile in tiles {
|
||||
if let Some(result) = work(&tile)? {
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
|
||||
}
|
||||
warp_and_write(&tile)?;
|
||||
}
|
||||
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if ctl.cancelled() {
|
||||
return Ok(out);
|
||||
}
|
||||
for (r, v) in done {
|
||||
out.try_write_region(r, &v)?;
|
||||
return Ok(Surface::new(fmt));
|
||||
}
|
||||
let mut out = out.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
out.prune();
|
||||
Ok(out)
|
||||
}
|
||||
@@ -551,6 +564,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The unclipped size matches the clipped bounds where nothing is clipped, saturates when it
|
||||
/// can't be represented, and is `None` for empty or singular inputs.
|
||||
#[test]
|
||||
fn output_size_saturates_overflows_and_preserves_filter_support() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
@@ -563,6 +578,20 @@ mod tests {
|
||||
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
|
||||
}
|
||||
|
||||
/// [`warp_output_clipped`] says yes exactly when [`warp_output_bounds`] clamps a side (#7807).
|
||||
#[test]
|
||||
fn output_clipped_matches_the_clamped_bounds() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
let at = |dx: f64| Homography([1.0, 0.0, dx, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let edge = f64::from(WARP_REACH) - 24.0;
|
||||
assert!(!warp_output_clipped(src, &at(edge)), "the right edge lands on the reach");
|
||||
assert_eq!(warp_output_bounds(src, &at(edge)).map(|b| b.x1), Some(WARP_REACH + 1));
|
||||
assert!(warp_output_clipped(src, &at(edge + 1.0)), "one pixel past it");
|
||||
assert_eq!(warp_output_bounds(src, &at(edge + 1.0)).map(|b| b.x1), Some(WARP_REACH + 1), "clamped");
|
||||
assert!(warp_output_clipped(src, &at(-f64::from(WARP_REACH) - 1.0)));
|
||||
assert!(!warp_output_clipped(Rect::EMPTY, &at(1e12)), "nothing to clip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
+106
-12
@@ -345,21 +345,41 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
let src_copy = surf.clone();
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
|
||||
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
|
||||
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
|
||||
let mut dabs = 0;
|
||||
for p in points {
|
||||
let mc = metric(&hd_inv, lud, lvd, *p);
|
||||
// Image-space footprint: map a metric circle's bounding points.
|
||||
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for k in 0..16 {
|
||||
let a = k as f64 / 16.0 * std::f64::consts::TAU;
|
||||
let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd);
|
||||
let (x, y) = hd.apply(u, v);
|
||||
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
|
||||
}
|
||||
let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1));
|
||||
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
|
||||
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for k in 0..16 {
|
||||
let a = k as f64 / 16.0 * std::f64::consts::TAU;
|
||||
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
|
||||
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
|
||||
}
|
||||
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
|
||||
};
|
||||
let area = footprint(&hd, lud, lvd, mc);
|
||||
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
|
||||
continue;
|
||||
}
|
||||
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
|
||||
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
|
||||
// decoding it would cost more than the few per-pixel reads it saves.
|
||||
win.area = footprint(&hs, lus, lvs, [mc[0] + offset[0], mc[1] + offset[1]]).inflate(2);
|
||||
let dest_px = u64::from(area.width()) * u64::from(area.height());
|
||||
if win.area.is_empty()
|
||||
|| win.area.width() > 4096
|
||||
|| win.area.height() > 4096
|
||||
|| u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px
|
||||
{
|
||||
win.area = Rect::new(0, 0, 0, 0);
|
||||
win.data.clear();
|
||||
} else {
|
||||
src_copy.read_region_into(win.area, &mut win.data);
|
||||
}
|
||||
let mut data = surf.read_region(area);
|
||||
let w = area.width() as usize;
|
||||
let mut changed = false;
|
||||
@@ -376,7 +396,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
// Source pixel at the same metric offset.
|
||||
let sm = [m[0] + offset[0], m[1] + offset[1]];
|
||||
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
|
||||
let s = sample(&src_copy, sx - 0.5, sy - 0.5, n);
|
||||
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
|
||||
for c in 0..n {
|
||||
px[c] += (s[c] - px[c]) * k;
|
||||
}
|
||||
@@ -390,11 +410,42 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
dabs
|
||||
}
|
||||
|
||||
fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec<f32> {
|
||||
/// Pixels of `area` read from the source surface (`read_region` layout).
|
||||
struct Window {
|
||||
area: Rect,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), with `corners` (resized to four
|
||||
/// pixels) as reused scratch. A footprint inside `win` is read from it, any other with one region
|
||||
/// read from `s`.
|
||||
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
|
||||
let n = out.len();
|
||||
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
|
||||
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
||||
let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2));
|
||||
(0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect()
|
||||
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
|
||||
let foot = Rect::new(x0, y0, x1.saturating_add(1), y1.saturating_add(1));
|
||||
if !win.area.contains_rect(&foot) && foot.width() == 2 && foot.height() == 2 {
|
||||
// Outside the window (or none): one region read looks the tile up once, where four
|
||||
// `read_pixel` calls would look it up four times. Same corner order as below.
|
||||
s.read_region_into(foot, corners);
|
||||
} else {
|
||||
corners.resize(4 * n, 0.0);
|
||||
let w = win.area.width() as usize;
|
||||
for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() {
|
||||
let dst = &mut corners[k * n..(k + 1) * n];
|
||||
if win.area.contains(px, py) {
|
||||
let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n;
|
||||
dst.copy_from_slice(&win.data[o..o + n]);
|
||||
} else {
|
||||
s.read_pixel(px, py, dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = corners;
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
*o = (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -487,4 +538,47 @@ mod tests {
|
||||
assert_eq!(n, 1);
|
||||
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_at_the_edge_of_i32_does_not_overflow() {
|
||||
let s = Surface::new(PixelFormat::RGBA8);
|
||||
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
|
||||
let (mut corners, mut out) = (Vec::new(), [1.0; 4]);
|
||||
sample(&s, &win, 3e9, 3e9, &mut corners, &mut out);
|
||||
assert_eq!(out, [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_interpolates_across_tile_edges() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let (a, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
|
||||
// Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge.
|
||||
s.fill_rect(Rect::new(0, 0, 256, 512), &a);
|
||||
s.fill_rect(Rect::new(256, 0, 512, 512), &b);
|
||||
let (mut corners, mut out) = (Vec::new(), [0.0; 4]);
|
||||
let want = [0.75, 0.0, 0.25, 1.0];
|
||||
// From the surface, from a window holding the footprint, and from one holding half of it.
|
||||
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] {
|
||||
let mut win = Window { area, data: Vec::new() };
|
||||
s.read_region_into(area, &mut win.data);
|
||||
sample(&s, &win, 255.25, 255.5, &mut corners, &mut out);
|
||||
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
|
||||
}
|
||||
// Four different corners, one per tile, so both axes of the corner order count.
|
||||
let mut q = Surface::new(PixelFormat::RGBA8);
|
||||
for (x, y, px) in [(255, 255, [1.0, 0.0, 0.0, 1.0]), (256, 255, [0.0, 1.0, 0.0, 1.0]), (255, 256, [0.0, 0.0, 1.0, 1.0]), (256, 256, [1.0; 4])] {
|
||||
q.fill_rect(Rect::new(x, y, x + 1, y + 1), &px);
|
||||
}
|
||||
let want = [0.375, 0.25, 0.75, 1.0];
|
||||
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260)] {
|
||||
let mut win = Window { area, data: Vec::new() };
|
||||
q.read_region_into(area, &mut win.data);
|
||||
sample(&q, &win, 255.25, 255.75, &mut corners, &mut out);
|
||||
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
|
||||
}
|
||||
// Outside every tile, the default (transparent) pixel.
|
||||
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
|
||||
sample(&s, &win, -10.5, -10.5, &mut corners, &mut out);
|
||||
assert_eq!(out, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
+52
-2
@@ -15,7 +15,7 @@
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
use photocraft_raster::{Interrupt, Surface, Tile};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::transform::{Interp, bicubic};
|
||||
@@ -75,6 +75,16 @@ pub fn place_source_projective(
|
||||
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
|
||||
/// added; pixels outside the warped area are transparent.
|
||||
pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp) -> Surface {
|
||||
mesh(src, src_rect, f, interp, &Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// [`warp_mesh_surface`] reporting progress per destination tile; `None` once `ctl` is cancelled.
|
||||
pub fn warp_mesh_surface_with(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Option<Surface> {
|
||||
let out = mesh(src, src_rect, f, interp, ctl);
|
||||
(!ctl.cancelled()).then_some(out)
|
||||
}
|
||||
|
||||
fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Surface {
|
||||
if src_rect.is_empty() {
|
||||
let fmt = src.format();
|
||||
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
|
||||
@@ -104,7 +114,7 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
|
||||
tris.push([a, a + w1 + 1, a + w1]);
|
||||
}
|
||||
}
|
||||
warp_triangles(src, src_rect, &verts, &tris, interp)
|
||||
triangles(src, src_rect, &verts, &tris, interp, ctl)
|
||||
}
|
||||
|
||||
/// Probe lines per axis [`mapped_lengths`] follows through the map.
|
||||
@@ -149,6 +159,12 @@ fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -
|
||||
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
|
||||
/// order is the depth order). Output has `src`'s format with alpha added.
|
||||
pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp) -> Surface {
|
||||
triangles(src, src_rect, verts, tris, interp, &Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
|
||||
/// the remaining tiles; the caller discards the result.
|
||||
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
|
||||
let mut fmt = src.format();
|
||||
let converted;
|
||||
let src = if fmt.alpha {
|
||||
@@ -208,7 +224,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let a = n - 1;
|
||||
// Tiles finished so far, for progress reported per tile (from any worker thread).
|
||||
let finished = std::sync::atomic::AtomicUsize::new(0);
|
||||
let total = tiles.len().max(1);
|
||||
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are putting the tiles in place.
|
||||
ctl.progress(0.98 * k as f32 / total as f32);
|
||||
if tris.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -430,6 +455,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
|
||||
if ctl.cancelled() {
|
||||
return out;
|
||||
}
|
||||
out.put_tiles(done);
|
||||
out
|
||||
}
|
||||
@@ -503,6 +531,28 @@ mod tests {
|
||||
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_interruptible_mesh_warp_reports_progress_and_stops_when_cancelled() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
|
||||
let r = s.content_bounds();
|
||||
let w = Warp::preset(WarpStyle::Arc, 30.0, [0.0, 0.0, 600.0, 400.0]);
|
||||
let map = |x: f64, y: f64| w.map(x, y);
|
||||
let reported = std::sync::Mutex::new(0.0f32);
|
||||
let progress = |p: f32| {
|
||||
let mut m = reported.lock().unwrap();
|
||||
*m = m.max(p);
|
||||
};
|
||||
let never = || false;
|
||||
let out = warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::new(&never, &progress)).expect("not cancelled");
|
||||
let want = warp_mesh_surface(&s, r, &map, Interp::Bicubic);
|
||||
assert_eq!(out.content_bounds(), want.content_bounds());
|
||||
assert_eq!(out.read_region(want.content_bounds()), want.read_region(want.content_bounds()));
|
||||
assert!(*reported.lock().unwrap() > 0.9);
|
||||
let always = || true;
|
||||
assert!(warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::cancel_only(&always)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
|
||||
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
|
||||
|
||||
@@ -1197,7 +1197,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
|
||||
}
|
||||
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
|
||||
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
|
||||
// New Document's Color Profile (#321) can name an .icc file.
|
||||
"file.new" => &["profile"],
|
||||
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
|
||||
_ => &[],
|
||||
};
|
||||
@@ -356,6 +358,13 @@ fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
|
||||
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") {
|
||||
return true;
|
||||
}
|
||||
// New Document skips exact `working` and `none`; the other working aliases resolve
|
||||
// the configured working space, which may be an .icc path.
|
||||
if id == "file.new" {
|
||||
return params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
|
||||
matches!(profile, "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
|
||||
});
|
||||
}
|
||||
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
|
||||
&& params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
|
||||
matches!(profile, "working" | "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
|
||||
@@ -759,6 +768,12 @@ mod tests {
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "adobe-rgb-compat"})).is_ok());
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working"})).is_ok());
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "none"})).is_ok());
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "default"})).is_err());
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working-cmyk"})).is_err());
|
||||
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
|
||||
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
|
||||
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
|
||||
@@ -887,7 +902,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
if matches!(id, "prefs.get" | "prefs.reset") {
|
||||
// These commands return settings or installed-profile paths; they do not accept a host
|
||||
// filesystem path as an automation input.
|
||||
if matches!(id, "prefs.get" | "prefs.reset" | "edit.installedProfiles") {
|
||||
return false;
|
||||
}
|
||||
// These registry descriptions refer to document vector paths, not host files.
|
||||
|
||||
@@ -257,3 +257,26 @@ fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache()
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
|
||||
/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read
|
||||
/// before the edit: a path outside the workspace is refused before that read, and the filter
|
||||
/// keeps the map it had.
|
||||
#[test]
|
||||
fn a_new_displace_map_outside_the_workspace_is_refused_before_it_is_read() {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(false);
|
||||
let params = |h: &Headless| {
|
||||
let d = h.session.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params.clone()
|
||||
};
|
||||
let before = params(&h);
|
||||
let map = photocraft_io::smart_map::displace_embedded_map(&before).expect("the open document's map, embedded");
|
||||
assert!(map.data.iter().all(|v| *v < 0.9), "the grey map");
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
let edit = json!({"layer": layer.0, "params": {"mapPath": f.outside("white.png")}});
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", edit.clone()));
|
||||
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": edit})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
assert_eq!(params(&h), before, "no white map was read into the filter");
|
||||
}
|
||||
@@ -261,13 +261,60 @@ fn lab() -> Profile {
|
||||
/// with `PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release regen`).
|
||||
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
|
||||
|
||||
/// Rec. 709 / sRGB primaries (xy).
|
||||
const BT709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]];
|
||||
/// DCI-P3 primaries (xy), used with D65 by Display P3.
|
||||
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
|
||||
/// Rec. 2020 primaries (xy).
|
||||
const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
|
||||
|
||||
/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
|
||||
fn rec709_trc() -> Curve {
|
||||
let a = 1.099;
|
||||
Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }
|
||||
}
|
||||
|
||||
/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video
|
||||
/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB,
|
||||
/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the
|
||||
/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are
|
||||
/// held, since no ICC curve represents those transfers. `None` for anything else.
|
||||
pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option<Profile> {
|
||||
let (prim, name) = match primaries {
|
||||
1 => (BT709, "BT.709"),
|
||||
9 => (BT2020, "Rec. ITU-R BT.2020"),
|
||||
12 => (P3, "Display P3"),
|
||||
_ => return None,
|
||||
};
|
||||
let transfer = if linear { 8 } else { transfer };
|
||||
let builtin = match (primaries, transfer) {
|
||||
(1, 13) => Some(Builtin::Srgb),
|
||||
(1, 8) => Some(Builtin::LinearSrgb),
|
||||
(12, 13) => Some(Builtin::DisplayP3),
|
||||
(9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(b) = builtin {
|
||||
return Some(b.profile().clone());
|
||||
}
|
||||
let (trc, curve) = match transfer {
|
||||
8 => (Curve::Gamma(1.0), "linear"),
|
||||
13 => (srgb_trc(), "sRGB curve"),
|
||||
1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"),
|
||||
4 => (Curve::Gamma(2.2), "gamma 2.2"),
|
||||
5 => (Curve::Gamma(2.8), "gamma 2.8"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, d65(), trc))
|
||||
}
|
||||
|
||||
fn build(b: Builtin) -> Profile {
|
||||
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
|
||||
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
|
||||
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
|
||||
BT709,
|
||||
P3,
|
||||
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
|
||||
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
|
||||
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
|
||||
BT2020,
|
||||
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
|
||||
);
|
||||
match b {
|
||||
@@ -279,12 +326,7 @@ fn build(b: Builtin) -> Profile {
|
||||
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
|
||||
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
|
||||
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
|
||||
Builtin::Rec2020 => {
|
||||
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
|
||||
let a = 1.099;
|
||||
let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] };
|
||||
rgb(b.description(), bt2020, d65(), trc)
|
||||
}
|
||||
Builtin::Rec2020 => rgb(b.description(), bt2020, d65(), rec709_trc()),
|
||||
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
|
||||
Builtin::SGray => gray(b.description(), srgb_trc()),
|
||||
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
|
||||
@@ -294,3 +336,30 @@ fn build(b: Builtin) -> Profile {
|
||||
Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cicp_maps_onto_the_builtins_and_linear_bt2020() {
|
||||
let desc = |p: Option<Profile>| p.map(|p| p.description);
|
||||
assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string()));
|
||||
|
||||
// An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve.
|
||||
let hlg = cicp_rgb(9, 18, true).unwrap();
|
||||
assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)]));
|
||||
assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix);
|
||||
assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description);
|
||||
assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile");
|
||||
|
||||
// HLG and PQ have no ICC curve; unknown primaries have no profile.
|
||||
assert!(cicp_rgb(9, 18, false).is_none());
|
||||
assert!(cicp_rgb(9, 16, false).is_none());
|
||||
assert!(cicp_rgb(2, 13, false).is_none());
|
||||
assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ the same.
|
||||
|
||||
Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the
|
||||
embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode.
|
||||
`bmp_palette` returns the colours the header declares, not the decoder's 256-entry padding.
|
||||
`encode_bmp_indexed` writes the inverse: indices plus a table as an 8-bit palette BMP.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! ITU-T H.273 colour descriptions (CICP: coding-independent code points), as HEIF `colr`
|
||||
//! `nclx` boxes and video streams declare them, and the HDR transfer functions of ITU-R BT.2100.
|
||||
//!
|
||||
//! PQ and HLG can't be written as an ICC tone curve, so an image using them is decoded here to
|
||||
//! linear light (32-bit float) instead: diffuse white (BT.2408's 203 cd/m²) becomes 1.0 and the
|
||||
//! highlights above it are kept as values over 1.0, as Photoshop holds HDR in a 32-bit document.
|
||||
//! The caller tags the result with a linear profile on the declared primaries.
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{Image, SampleType};
|
||||
|
||||
/// A colour description: the four H.273 code points as written in the file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Cicp {
|
||||
/// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65…
|
||||
pub primaries: u16,
|
||||
/// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 8 linear, 16 PQ, 18 HLG…
|
||||
pub transfer: u16,
|
||||
/// Matrix coefficients.
|
||||
pub matrix: u16,
|
||||
/// Full-range samples rather than studio swing.
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
impl Cicp {
|
||||
/// SMPTE ST 2084 / BT.2100 perceptual quantizer.
|
||||
pub const TRANSFER_PQ: u16 = 16;
|
||||
/// ARIB STD-B67 / BT.2100 hybrid log-gamma.
|
||||
pub const TRANSFER_HLG: u16 = 18;
|
||||
|
||||
/// PQ or HLG: samples that [`linearize`] decodes to linear light.
|
||||
pub fn is_hdr(self) -> bool {
|
||||
matches!(self.transfer, Cicp::TRANSFER_PQ | Cicp::TRANSFER_HLG)
|
||||
}
|
||||
}
|
||||
|
||||
/// The luminance that becomes 1.0: BT.2408's HDR reference (diffuse) white.
|
||||
pub const REFERENCE_WHITE_NITS: f32 = 203.0;
|
||||
/// The nominal display peak HLG is rendered for (BT.2100's reference display).
|
||||
pub const HLG_PEAK_NITS: f32 = 1000.0;
|
||||
/// PQ's absolute peak.
|
||||
pub const PQ_PEAK_NITS: f32 = 10_000.0;
|
||||
|
||||
/// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1).
|
||||
pub fn hlg_inverse_oetf(e: f32) -> f32 {
|
||||
const A: f32 = 0.178_832_77;
|
||||
const B: f32 = 1.0 - 4.0 * A;
|
||||
const C: f32 = 0.559_910_7;
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if e <= 0.5 { e * e / 3.0 } else { (((e - C) / A).exp() + B) / 12.0 }
|
||||
}
|
||||
|
||||
/// BT.2100 HLG signal to display light relative to [`REFERENCE_WHITE_NITS`]: the inverse OETF,
|
||||
/// then the OOTF for a [`HLG_PEAK_NITS`] display (system gamma 1.2, zero black level), whose
|
||||
/// luminance uses the weights of the `luma` primaries.
|
||||
pub fn hlg_to_linear(rgb: [f32; 3], luma: [f32; 3]) -> [f32; 3] {
|
||||
let s = rgb.map(hlg_inverse_oetf);
|
||||
let ys = luma[0] * s[0] + luma[1] * s[1] + luma[2] * s[2];
|
||||
// γ = 1.2 + 0.42·log10(Lw / 1000) is exactly 1.2 at 1000 cd/m².
|
||||
let gain = if ys > 0.0 { HLG_PEAK_NITS * ys.powf(0.2) / REFERENCE_WHITE_NITS } else { 0.0 };
|
||||
s.map(|v| v * gain)
|
||||
}
|
||||
|
||||
/// SMPTE ST 2084 (PQ) signal `E'` (0..1) to display light relative to [`REFERENCE_WHITE_NITS`].
|
||||
pub fn pq_to_linear(e: f32) -> f32 {
|
||||
const M1: f32 = 2610.0 / 16384.0;
|
||||
const M2: f32 = 2523.0 / 4096.0 * 128.0;
|
||||
const C1: f32 = 3424.0 / 4096.0;
|
||||
const C2: f32 = 2413.0 / 4096.0 * 32.0;
|
||||
const C3: f32 = 2392.0 / 4096.0 * 32.0;
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let p = e.powf(1.0 / M2);
|
||||
let y = ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1);
|
||||
y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS
|
||||
}
|
||||
|
||||
/// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the
|
||||
/// space BT.2100 defines HLG in).
|
||||
fn luma_weights(code: u16) -> [f32; 3] {
|
||||
match code {
|
||||
1 => [0.2126, 0.7152, 0.0722],
|
||||
12 => [0.2290, 0.6917, 0.0793],
|
||||
_ => [0.2627, 0.6780, 0.0593],
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` (RGB or RGBA, decoded with `cicp`'s PQ or HLG transfer) as linear light in 32-bit float,
|
||||
/// alpha unchanged. Its ICC profile is dropped: whatever it said described the encoded samples,
|
||||
/// not linear light. Anything else comes back unchanged.
|
||||
pub fn linearize(img: Image, cicp: Cicp) -> Result<Image, CodecError> {
|
||||
let layout = img.layout();
|
||||
if !cicp.is_hdr() || !layout.is_rgb() {
|
||||
return Ok(img);
|
||||
}
|
||||
let (w, h) = img.dimensions();
|
||||
let ch = layout.channels();
|
||||
let mut v = img.to_normalized();
|
||||
let luma = luma_weights(cicp.primaries);
|
||||
for px in v.chunks_exact_mut(ch) {
|
||||
let [r, g, b, ..] = *px else { continue };
|
||||
let out = if cicp.transfer == Cicp::TRANSFER_HLG { hlg_to_linear([r, g, b], luma) } else { [r, g, b].map(pq_to_linear) };
|
||||
px[..3].copy_from_slice(&out);
|
||||
}
|
||||
let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?;
|
||||
out.meta = img.meta;
|
||||
out.warnings = img.warnings;
|
||||
out.cicp = Some(cicp);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::image::ChannelLayout;
|
||||
|
||||
const BT2020: [f32; 3] = [0.2627, 0.6780, 0.0593];
|
||||
|
||||
fn close(a: f32, b: f32, tol: f32) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hlg_inverse_oetf_matches_bt2100() {
|
||||
assert_eq!(hlg_inverse_oetf(0.0), 0.0);
|
||||
// The two segments meet at E' = 0.5, E = 1/12.
|
||||
assert!(close(hlg_inverse_oetf(0.5), 1.0 / 12.0, 1e-6));
|
||||
assert!(close(hlg_inverse_oetf(0.500_001), 1.0 / 12.0, 1e-5));
|
||||
assert!(close(hlg_inverse_oetf(1.0), 1.0, 1e-4));
|
||||
// BT.2408's HLG reference white, 75 % signal, is E ≈ 0.265.
|
||||
assert!(close(hlg_inverse_oetf(0.75), 0.2650, 1e-3));
|
||||
for bad in [f32::NAN, f32::INFINITY, -1.0] {
|
||||
assert!(hlg_inverse_oetf(bad).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hlg_reference_white_is_one_and_highlights_are_kept() {
|
||||
// BT.2408: a 75 % HLG grey on a 1000 cd/m² display is 203 cd/m², our 1.0.
|
||||
let white = hlg_to_linear([0.75; 3], BT2020);
|
||||
assert!(white.iter().all(|&v| close(v, 1.0, 0.005)), "{white:?}");
|
||||
// Peak signal is the display peak: 1000 / 203.
|
||||
let peak = hlg_to_linear([1.0; 3], BT2020);
|
||||
assert!(peak.iter().all(|&v| close(v, 1000.0 / 203.0, 0.01)), "{peak:?}");
|
||||
// 50 % grey: 1000 · (1/12)^1.2 ≈ 50.7 cd/m².
|
||||
let mid = hlg_to_linear([0.5; 3], BT2020);
|
||||
assert!(mid.iter().all(|&v| close(v * 203.0, 50.71, 0.1)), "{mid:?}");
|
||||
assert_eq!(hlg_to_linear([0.0; 3], BT2020), [0.0; 3]);
|
||||
// A saturated colour keeps its hue: only red lit stays only red.
|
||||
let red = hlg_to_linear([0.75, 0.0, 0.0], BT2020);
|
||||
assert!(red[0] > 0.0 && red[1] == 0.0 && red[2] == 0.0, "{red:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_matches_st2084() {
|
||||
assert_eq!(pq_to_linear(0.0), 0.0);
|
||||
assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05));
|
||||
// 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408).
|
||||
assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807));
|
||||
assert!(pq_to_linear(f32::NAN).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linearize_decodes_hlg_to_float_and_keeps_alpha() {
|
||||
let hlg = Cicp { primaries: 9, transfer: Cicp::TRANSFER_HLG, matrix: 9, full_range: true };
|
||||
let three_quarters = (0.75f32 * 65535.0).round() as u16;
|
||||
let img = Image::from_u16(2, 1, ChannelLayout::Rgba, &[three_quarters, three_quarters, three_quarters, 65535, 0, 0, 0, 32768])
|
||||
.unwrap()
|
||||
.with_icc(Some(b"encoded-profile".to_vec()));
|
||||
let out = linearize(img, hlg).unwrap();
|
||||
assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg)));
|
||||
let v = out.to_normalized();
|
||||
assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}");
|
||||
assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linearize_leaves_sdr_untouched() {
|
||||
let srgb = Cicp { primaries: 1, transfer: 13, matrix: 6, full_range: false };
|
||||
let img = Image::from_u16(1, 1, ChannelLayout::Rgb, &[1, 2, 3]).unwrap();
|
||||
assert_eq!(linearize(img.clone(), srgb).unwrap(), img);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fn err(e: photocraft_heif::Error) -> CodecError {
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
use crate::cicp::Cicp;
|
||||
use crate::image::{ChannelLayout, Metadata, SampleType};
|
||||
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
@@ -51,12 +52,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R
|
||||
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
|
||||
img.icc = decoded.icc;
|
||||
img.cicp = decoded.nclx.map(|n| Cicp { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix, full_range: n.full_range });
|
||||
img.meta = Metadata {
|
||||
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
|
||||
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(img)
|
||||
// A 10-bit HDR HEIC (BT.2100 HLG or PQ) opens as linear light in 32-bit float (#6752): no
|
||||
// ICC curve can describe those transfers, and an 8/16-bit document would clip the highlights.
|
||||
match img.cicp.filter(|c| c.is_hdr()) {
|
||||
Some(c) => {
|
||||
limits.check(decoded.width, decoded.height, layout, SampleType::F32)?;
|
||||
crate::cicp::linearize(img, c)
|
||||
}
|
||||
None => Ok(img),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//! these wins: the APP13 Photoshop ResolutionInfo, the XMP `tiff:` resolution, the EXIF IFD0
|
||||
//! resolution, and last the JFIF density (a unit-0 JFIF density is only an aspect ratio and
|
||||
//! never counts). Cameras record their resolution in EXIF only, and many tools write a default
|
||||
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export the JFIF
|
||||
//! density, the EXIF and the XMP resolution are all written as the image's resolution.
|
||||
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export RGB and gray
|
||||
//! images carry density in JFIF; CMYK omits JFIF APP0, which is invalid for four components, and
|
||||
//! carries an available density in EXIF instead.
|
||||
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
@@ -263,7 +264,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.set_sampling_factor(sampling);
|
||||
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e);
|
||||
if opts.embed_metadata {
|
||||
if let Some((x, y)) = img.meta.dpi
|
||||
if img.layout() != ChannelLayout::Cmyk
|
||||
&& let Some((x, y)) = img.meta.dpi
|
||||
&& x >= 1.0
|
||||
&& y >= 1.0
|
||||
{
|
||||
@@ -271,7 +273,16 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
// Metadata too large for its one segment is left out (and reported by the fidelity
|
||||
// warnings) rather than failing the whole export.
|
||||
if let Some(seg) = img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi)) {
|
||||
let exif = if img.layout() == ChannelLayout::Cmyk {
|
||||
match img.meta.exif.as_deref() {
|
||||
Some(exif) => exif_segment_with_resolution(exif, img.meta.dpi),
|
||||
None if usable_dpi(img.meta.dpi) => exif_segment_with_resolution(&[], img.meta.dpi),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi))
|
||||
};
|
||||
if let Some(seg) = exif {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(seg) = img.meta.xmp.as_deref().and_then(|x| xmp_segment(x, img.meta.dpi)) {
|
||||
@@ -284,9 +295,58 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.add_icc_profile(icc).map_err(e)?;
|
||||
}
|
||||
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
|
||||
if img.layout() == ChannelLayout::Cmyk {
|
||||
strip_jfif_app0(&mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// JFIF APP0 requires one or three image components. A four-component CMYK JPEG must not carry
|
||||
/// that marker; keep Adobe APP14, which identifies the CMYK encoding instead.
|
||||
fn strip_jfif_app0(bytes: &mut Vec<u8>) {
|
||||
if !bytes.starts_with(&[0xff, 0xd8]) {
|
||||
return;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
loop {
|
||||
if bytes.get(i) != Some(&0xff) {
|
||||
return;
|
||||
}
|
||||
let Some(&marker) = bytes.get(i.saturating_add(1)) else { return };
|
||||
if matches!(marker, 0xda | 0xd9) {
|
||||
return;
|
||||
}
|
||||
if marker == 0xff {
|
||||
i = i.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if marker == 0x01 || (0xd0..=0xd8).contains(&marker) {
|
||||
i = i.saturating_add(2);
|
||||
continue;
|
||||
}
|
||||
let Some(length_bytes) = i.checked_add(2).and_then(|at| bytes.get(at..at.checked_add(2)?)) else { return };
|
||||
let Ok(length_bytes) = <[u8; 2]>::try_from(length_bytes) else { return };
|
||||
let length = usize::from(u16::from_be_bytes(length_bytes));
|
||||
if length < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(end) = i.checked_add(2).and_then(|at| at.checked_add(length)) else { return };
|
||||
if end > bytes.len() {
|
||||
return;
|
||||
}
|
||||
if marker == 0xe0 && bytes.get(i.saturating_add(4)..i.saturating_add(9)) == Some(b"JFIF\0") {
|
||||
bytes.drain(i..end);
|
||||
} else {
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether both resolution values are finite and within the supported metadata range.
|
||||
fn usable_dpi(dpi: Option<(f32, f32)>) -> bool {
|
||||
dpi.is_some_and(|(x, y)| x.is_finite() && y.is_finite() && (1.0..=1_000_000.0).contains(&x) && (1.0..=1_000_000.0).contains(&y))
|
||||
}
|
||||
|
||||
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
|
||||
const MAX_SEGMENT: usize = 65533;
|
||||
|
||||
@@ -297,12 +357,18 @@ const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
|
||||
|
||||
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
|
||||
pub(crate) fn exif_segment(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again; and the
|
||||
// resolution is the one written to JFIF, not the source's (#1691).
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again; and store
|
||||
// the output resolution, not the source's (#1691).
|
||||
let seg = [EXIF_HEADER, export_exif(exif, dpi).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// Build an EXIF APP1 segment, adding missing resolution tags when the source metadata is valid.
|
||||
fn exif_segment_with_resolution(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
|
||||
let seg = [EXIF_HEADER, crate::resolution::export_exif_adding_missing_resolution(exif, dpi).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
|
||||
/// properties are left out; `None` when it still doesn't fit.
|
||||
pub(crate) fn xmp_segment(xmp: &str, dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
|
||||
@@ -347,3 +413,74 @@ fn remove_element(xmp: &str, name: &str) -> String {
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Format, Metadata};
|
||||
|
||||
/// Build a little-endian TIFF with an empty IFD0 for metadata tests.
|
||||
fn empty_tiff() -> Vec<u8> {
|
||||
// Little-endian TIFF with an empty IFD0 and no next IFD.
|
||||
b"II*\0\x08\0\0\0\0\0\0\0\0\0\0\0".to_vec()
|
||||
}
|
||||
|
||||
/// Return whether the JPEG begins with a JFIF APP0 segment.
|
||||
fn has_jfif_app0(bytes: &[u8]) -> bool {
|
||||
bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0")
|
||||
}
|
||||
|
||||
/// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker.
|
||||
#[test]
|
||||
fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 32, 64, 96, 255, 224, 192, 160]).unwrap();
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
let meta = scan_metadata(&bytes);
|
||||
assert!(!has_jfif_app0(&bytes));
|
||||
assert_eq!(meta.frame, Some((2, 1, 4)));
|
||||
assert_eq!(meta.adobe_transform, Some(0));
|
||||
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().layout(), ChannelLayout::Cmyk);
|
||||
}
|
||||
|
||||
/// CMYK JPEG output stores image resolution in EXIF when the source has no EXIF.
|
||||
#[test]
|
||||
fn cmyk_jpeg_preserves_dpi_without_source_exif() {
|
||||
let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap();
|
||||
img.meta.dpi = Some((300.0, 150.0));
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
assert!(!has_jfif_app0(&bytes));
|
||||
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((300.0, 150.0)));
|
||||
}
|
||||
|
||||
/// CMYK JPEG output adds missing EXIF resolution tags to existing metadata.
|
||||
#[test]
|
||||
fn cmyk_jpeg_preserves_dpi_with_source_exif_without_resolution_tags() {
|
||||
let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap();
|
||||
img.meta = Metadata { exif: Some(empty_tiff()), dpi: Some((240.0, 120.0)), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((240.0, 120.0)));
|
||||
}
|
||||
|
||||
/// Adding missing resolution tags makes them readable from the rewritten IFD0.
|
||||
#[test]
|
||||
fn exif_with_resolution_adds_missing_ifd0_entries() {
|
||||
let exif = empty_tiff();
|
||||
let rewritten = crate::resolution::export_exif_adding_missing_resolution(&exif, Some((240.0, 120.0)));
|
||||
|
||||
assert_eq!(crate::resolution::exif_resolution(&rewritten), Some((240.0, 120.0)));
|
||||
}
|
||||
|
||||
/// RGB JPEG output continues to store image resolution in JFIF density fields.
|
||||
#[test]
|
||||
fn rgb_jpeg_keeps_jfif_density() {
|
||||
let mut img = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U8, vec![12, 34, 56]).unwrap();
|
||||
img.meta.dpi = Some((96.0, 96.0));
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
assert!(has_jfif_app0(&bytes));
|
||||
assert_eq!(scan_metadata(&bytes).dpi, Some((96.0, 96.0)));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ use super::tiff_ifd::{File, Ifd};
|
||||
pub(crate) const TAG_EXIF_IFD: u16 = 34665;
|
||||
/// IFD0's pointer to the GPS directory.
|
||||
pub(crate) const TAG_GPS_IFD: u16 = 34853;
|
||||
/// The Exif directory's PixelXDimension and PixelYDimension: the size of the image the
|
||||
/// directory describes.
|
||||
const TAG_PIXEL_X_DIMENSION: u16 = 40962;
|
||||
const TAG_PIXEL_Y_DIMENSION: u16 = 40963;
|
||||
/// The Exif directory's pointer to the Interoperability directory (not carried: it holds only
|
||||
/// the interoperability index, and a copied pointer would point nowhere).
|
||||
const TAG_INTEROP_IFD: u16 = 40965;
|
||||
@@ -92,7 +96,20 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
|
||||
/// is left are left out (the small descriptive directories come first, so a huge maker note
|
||||
/// cannot crowd out the GPS position).
|
||||
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
|
||||
let f = File::parse(b)?;
|
||||
read_file(File::parse(b)?)
|
||||
}
|
||||
|
||||
/// The EXIF parts of a camera raw built on TIFF (NEF, ARW, CR2, DNG, Panasonic RW2), as
|
||||
/// [`read`] gives them, without the Exif directory's PixelXDimension and PixelYDimension: in a
|
||||
/// raw they give the sensor's or the preview's size, not the developed image's.
|
||||
pub(crate) fn read_raw(b: &[u8]) -> Option<ExifDirs> {
|
||||
let mut dirs = read_file(File::parse_raw(b)?)?;
|
||||
dirs.exif.retain(|f| !matches!(f.tag, TAG_PIXEL_X_DIMENSION | TAG_PIXEL_Y_DIMENSION));
|
||||
Some(dirs)
|
||||
}
|
||||
|
||||
fn read_file(f: File<'_>) -> Option<ExifDirs> {
|
||||
let b = f.b;
|
||||
if f.big {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,20 @@ impl<'a> File<'a> {
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// [`parse`](Self::parse), also taking a Panasonic RW2 raw, whose classic little-endian TIFF
|
||||
/// header has 0x55 (`IIU\0`) in place of 42.
|
||||
pub fn parse_raw(b: &'a [u8]) -> Option<Self> {
|
||||
if let Some(f) = Self::parse(b) {
|
||||
return Some(f);
|
||||
}
|
||||
let mut f = File { b, le: true, big: false, first_ifd: 0 };
|
||||
if b.get(0..2)? != b"II" || f.u16_at(2)? != 0x55 {
|
||||
return None;
|
||||
}
|
||||
f.first_ifd = u64::from(f.u32_at(4)?);
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Length of the header; no directory can start inside it.
|
||||
pub fn header_len(&self) -> u64 {
|
||||
if self.big { 16 } else { 8 }
|
||||
|
||||
@@ -35,7 +35,51 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
|
||||
|
||||
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
|
||||
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
|
||||
let mut palette = decoder.get_palette().map(|palette| palette.to_vec());
|
||||
// The decoder always hands back 256 entries (it pads a short table with black to survive
|
||||
// corrupt files); the file's own count is what the document's colour table should hold.
|
||||
if let (Some(p), Some(n)) = (palette.as_mut(), bmp_palette_len(bytes)) {
|
||||
p.truncate(n.max(1));
|
||||
}
|
||||
Ok(palette)
|
||||
}
|
||||
|
||||
/// How many colours a palette BMP's header declares: `biClrUsed`, or 2^bits when it is 0.
|
||||
/// `None` for a header this does not recognise, which leaves the table as decoded.
|
||||
fn bmp_palette_len(bytes: &[u8]) -> Option<usize> {
|
||||
let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let (bit_count, used) = match u32_at(14)? {
|
||||
// OS/2 BITMAPCOREHEADER: no colour count, the bit depth sets the table size.
|
||||
12 => (u16_at(24)?, 0),
|
||||
n if n >= 40 => (u16_at(28)?, u32_at(46)?),
|
||||
_ => return None,
|
||||
};
|
||||
if !(1..=8).contains(&bit_count) {
|
||||
return None;
|
||||
}
|
||||
let max = 1usize << bit_count;
|
||||
Some(if used == 0 { max } else { (used as usize).min(max) })
|
||||
}
|
||||
|
||||
/// Writes an 8-bit palette BMP: `indices` row-major, `palette` RGB entries (1..=256). The
|
||||
/// counterpart of [`bmp_palette`], for Indexed Color documents.
|
||||
pub(crate) fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result<Vec<u8>, CodecError> {
|
||||
let f = Format::Bmp;
|
||||
if width == 0 || height == 0 || u64::from(width) * u64::from(height) != indices.len() as u64 {
|
||||
return Err(CodecError::InvalidImage("index data does not match the size".into()));
|
||||
}
|
||||
if palette.is_empty() || palette.len() > 256 {
|
||||
return Err(CodecError::InvalidImage("a palette BMP holds 1 to 256 colours".into()));
|
||||
}
|
||||
if indices.iter().any(|&i| usize::from(i) >= palette.len()) {
|
||||
return Err(CodecError::InvalidImage("palette index out of range".into()));
|
||||
}
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image::codecs::bmp::BmpEncoder::new(&mut out)
|
||||
.encode_with_palette(indices, width, height, image::ExtendedColorType::L8, Some(palette))
|
||||
.map_err(|e| CodecError::encode(f, e))?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// The `image` decoder for `f` with our limits, its header read and its size checked.
|
||||
@@ -183,3 +227,45 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
|
||||
}
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indexed_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn palette_bmp_roundtrips_its_table_and_pixels() {
|
||||
// 3 px wide: rows are padded to 4 bytes, so a wrong stride shows in the second row.
|
||||
let pal = [[255, 0, 0], [0, 0, 255], [10, 20, 30]];
|
||||
let idx = [0u8, 1, 2, 2, 1, 0];
|
||||
let bytes = encode_bmp_indexed(3, 2, &idx, &pal).unwrap();
|
||||
assert_eq!(bmp_palette(&bytes).unwrap().as_deref(), Some(&pal[..]), "the table survives, not a 256-entry grey ramp");
|
||||
let img = decode(Format::Bmp, &bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(img.dimensions(), (3, 2));
|
||||
let rgb = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let want: Vec<u8> = idx.iter().flat_map(|&i| pal[usize::from(i)]).collect();
|
||||
assert_eq!(rgb.data(), want.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_len_follows_the_header() {
|
||||
let mut bytes = encode_bmp_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]]).unwrap();
|
||||
assert_eq!(bmp_palette_len(&bytes), Some(2));
|
||||
// biClrUsed = 0 means the full 2^bits table.
|
||||
bytes[46..50].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert_eq!(bmp_palette_len(&bytes), Some(256));
|
||||
// A short or unknown header leaves the decoded table alone instead of panicking.
|
||||
assert_eq!(bmp_palette_len(&bytes[..30]), None);
|
||||
bytes[14..18].copy_from_slice(&7u32.to_le_bytes());
|
||||
assert_eq!(bmp_palette_len(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_bmp_rejects_bad_input() {
|
||||
let pal = [[0, 0, 0], [255, 255, 255]];
|
||||
assert!(encode_bmp_indexed(3, 2, &[0; 5], &pal).is_err(), "size mismatch");
|
||||
assert!(encode_bmp_indexed(0, 2, &[], &pal).is_err(), "empty");
|
||||
assert!(encode_bmp_indexed(2, 1, &[0, 2], &pal).is_err(), "index past the table");
|
||||
assert!(encode_bmp_indexed(1, 1, &[0], &[]).is_err(), "no colours");
|
||||
assert!(encode_bmp_indexed(1, 1, &[0], &[[0; 3]; 257]).is_err(), "more than 256 colours");
|
||||
}
|
||||
}
|
||||
@@ -454,6 +454,9 @@ pub struct Image {
|
||||
data: Vec<u8>,
|
||||
/// Embedded ICC profile (must describe `layout`'s colour model).
|
||||
pub icc: Option<Vec<u8>>,
|
||||
/// The file's CICP colour description (a HEIF `nclx`), when it has one. Only an image whose
|
||||
/// `icc` is `None` relies on it.
|
||||
pub cicp: Option<crate::cicp::Cicp>,
|
||||
pub meta: Metadata,
|
||||
/// What the decoder noticed about the file: empty for a complete, single image and for
|
||||
/// images built in memory. Encoders ignore it.
|
||||
@@ -468,7 +471,7 @@ impl Image {
|
||||
/// A zero-filled image, or an error if the byte size overflows `usize`.
|
||||
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
|
||||
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Wrap raw interleaved, native-endian bytes.
|
||||
@@ -477,7 +480,7 @@ impl Image {
|
||||
if data.len() != expected {
|
||||
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
|
||||
}
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
@@ -646,6 +649,7 @@ impl Image {
|
||||
sample,
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
cicp: if layout.same_model(self.layout) { self.cicp } else { None },
|
||||
meta: self.meta.clone(),
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cicp;
|
||||
mod codecs;
|
||||
mod error;
|
||||
pub mod exif_color;
|
||||
@@ -25,7 +26,7 @@ pub mod orientation;
|
||||
pub mod resolution;
|
||||
pub mod source_settings;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::cicp::Cicp;
|
||||
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
|
||||
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
|
||||
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
|
||||
@@ -139,6 +140,12 @@ pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
}
|
||||
|
||||
/// Encodes `indices` (row-major, one byte per pixel) as an 8-bit palette BMP with the colour
|
||||
/// table `palette` (1 to 256 entries). Used for Indexed Color documents; BMP has no transparency.
|
||||
pub fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result<Vec<u8>, CodecError> {
|
||||
via_image::encode_bmp_indexed(width, height, indices, palette)
|
||||
}
|
||||
|
||||
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
|
||||
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
|
||||
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
|
||||
@@ -172,6 +179,18 @@ pub fn tiff_page_orientation(bytes: &[u8], page: usize) -> u16 {
|
||||
tiff::orientation(bytes, Some(page))
|
||||
}
|
||||
|
||||
/// The EXIF block (`Metadata::exif`) of a camera raw built on TIFF (NEF, ARW, CR2, DNG,
|
||||
/// Panasonic RW2), for the image developed from it: IFD0's descriptive tags (make, model,
|
||||
/// date…) and the Exif and GPS directories, as a TIFF opens with. Orientation is not part of
|
||||
/// it, nor the Exif directory's pixel dimensions (the sensor's or the preview's size). When those
|
||||
/// were all its Exif directory held, the block is IFD0's tags alone, so the camera's make and
|
||||
/// model are still kept. `None` when the file is not built on a classic TIFF stream or has none
|
||||
/// of these tags.
|
||||
pub fn raw_exif(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let dirs = crate::codecs::tiff_exif::read_raw(bytes).filter(|d| d.has_directories() || !d.ifd0.is_empty())?;
|
||||
Some(crate::codecs::tiff_exif::block(&dirs))
|
||||
}
|
||||
|
||||
/// The limits check and the orientation, shared by every decoder.
|
||||
fn finish_decode(img: Image, o: u16, opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
let l = &opts.limits;
|
||||
|
||||
+142
-10
@@ -159,23 +159,51 @@ pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> {
|
||||
ppi_pair(x * scale, y * scale)
|
||||
}
|
||||
|
||||
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when there is
|
||||
/// nothing to change: no resolution entries, already that resolution, or not parseable.
|
||||
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when it has no
|
||||
/// resolution entries, already records that resolution, or cannot be parsed.
|
||||
///
|
||||
/// Every well-formed XResolution / YResolution (one RATIONAL) gets the new value in place and
|
||||
/// every well-formed ResolutionUnit (one SHORT) becomes 2 (inch); all other bytes are kept.
|
||||
/// A resolution entry that can't be rewritten safely (another type or count, or a value outside
|
||||
/// the block) is removed from IFD0 instead, so no stale value survives: the later entries move
|
||||
/// up, the entry count and next-IFD pointer follow, and the freed bytes at the table's end are
|
||||
/// zeroed. Nothing else moves, so every offset in the block stays valid.
|
||||
/// Existing well-formed XResolution / YResolution (one RATIONAL) entries are rewritten in place,
|
||||
/// and every well-formed ResolutionUnit (one SHORT) becomes 2 (inch). Malformed entries are
|
||||
/// removed from IFD0 instead, so no stale value survives; all other bytes are kept.
|
||||
pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
|
||||
exif_with_resolution_inner(exif, ppi, false)
|
||||
}
|
||||
|
||||
/// Like [`exif_with_resolution`], but adds X/Y resolution tags when a valid IFD0 has none.
|
||||
/// Existing TIFF data stays in place, so its offsets remain valid. Used by CMYK JPEG, which
|
||||
/// cannot carry a JFIF density marker.
|
||||
pub(crate) fn exif_with_resolution_adding_missing(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
|
||||
exif_with_resolution_inner(exif, ppi, true)
|
||||
}
|
||||
|
||||
/// Rewrite valid IFD0 resolution tags and optionally append tags that are missing or malformed.
|
||||
fn exif_with_resolution_inner(exif: &[u8], ppi: (f32, f32), add_missing: bool) -> Cow<'_, [u8]> {
|
||||
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
if exif.is_empty() {
|
||||
if !add_missing {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
// An image with DPI but no source EXIF still needs a valid EXIF block when the output
|
||||
// format cannot carry resolution in its own header (CMYK JPEG has no valid JFIF marker).
|
||||
return Cow::Owned(exif_with_resolution_inner(b"II*\0\x08\0\0\0\0\0\0\0\0\0\0", ppi, true).into_owned());
|
||||
}
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) };
|
||||
if ifd.fields.is_empty() || exif_resolution(body).is_some_and(|r| same(r, ppi)) {
|
||||
if exif_resolution(body).is_some_and(|r| same(r, ppi)) {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let missing_xy = ifd.first_of(TAG_X_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none()
|
||||
|| ifd.first_of(TAG_Y_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none();
|
||||
if add_missing && missing_xy {
|
||||
let Some(body) = append_resolution_ifd(body, &ifd, ppi) else { return Cow::Borrowed(exif) };
|
||||
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
|
||||
full.extend_from_slice(&body);
|
||||
return Cow::Owned(full);
|
||||
}
|
||||
if ifd.fields.is_empty() {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let order = ifd.order;
|
||||
@@ -207,6 +235,98 @@ pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
|
||||
if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) }
|
||||
}
|
||||
|
||||
/// Append a replacement IFD0 with fresh resolution tags. Every other entry keeps its original
|
||||
/// bytes and offsets, since the TIFF block's existing data remains where it was.
|
||||
fn append_resolution_ifd(body: &[u8], ifd: &Ifd0, ppi: (f32, f32)) -> Option<Vec<u8>> {
|
||||
let (count_bytes, next_bytes, alignment) = if ifd.big { (8usize, 8usize, 8usize) } else { (2usize, 4usize, 2usize) };
|
||||
let mut entries = Vec::with_capacity(ifd.count.saturating_add(3));
|
||||
for index in 0..ifd.count {
|
||||
let entry = ifd.first.checked_add(index.checked_mul(ifd.entry_bytes)?)?;
|
||||
let tag = ifd.order.u16(body, entry)?;
|
||||
if matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
|
||||
continue;
|
||||
}
|
||||
entries.push((tag, body.get(entry..entry.checked_add(ifd.entry_bytes)?)?.to_vec()));
|
||||
}
|
||||
let count = entries.len().checked_add(3)?;
|
||||
if !ifd.big && count > usize::from(u16::MAX) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_ifd = align_up(body.len(), alignment)?;
|
||||
let table_end = new_ifd.checked_add(count_bytes)?.checked_add(count.checked_mul(ifd.entry_bytes)?)?;
|
||||
let next_at = ifd.first.checked_add(ifd.count.checked_mul(ifd.entry_bytes)?)?;
|
||||
let next_ifd = body.get(next_at..next_at.checked_add(next_bytes)?)?;
|
||||
let rational_at = align_up(table_end.checked_add(next_bytes)?, if ifd.big { 8 } else { 4 })?;
|
||||
let y_rational_at = rational_at.checked_add(8)?;
|
||||
if !ifd.big && (new_ifd > u32::MAX as usize || y_rational_at.checked_add(8)? > u32::MAX as usize) {
|
||||
return None;
|
||||
}
|
||||
|
||||
entries.push((TAG_X_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_X_RESOLUTION, u64::try_from(rational_at).ok()?)?));
|
||||
entries.push((TAG_Y_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_Y_RESOLUTION, u64::try_from(y_rational_at).ok()?)?));
|
||||
entries.push((TAG_RESOLUTION_UNIT, resolution_entry(ifd.order, ifd.big, TAG_RESOLUTION_UNIT, 2)?));
|
||||
entries.sort_by_key(|(tag, _)| *tag);
|
||||
|
||||
let mut out = body.to_vec();
|
||||
out.resize(new_ifd, 0);
|
||||
if ifd.big {
|
||||
out.extend_from_slice(&put_u64(ifd.order, count as u64));
|
||||
} else {
|
||||
out.extend_from_slice(&put_u16(ifd.order, count as u16));
|
||||
}
|
||||
for (_, entry) in entries {
|
||||
out.extend_from_slice(&entry);
|
||||
}
|
||||
out.extend_from_slice(next_ifd);
|
||||
out.resize(rational_at, 0);
|
||||
let (xn, xd) = rational(ppi.0);
|
||||
let (yn, yd) = rational(ppi.1);
|
||||
out.extend_from_slice(&put_u32(ifd.order, xn));
|
||||
out.extend_from_slice(&put_u32(ifd.order, xd));
|
||||
out.extend_from_slice(&put_u32(ifd.order, yn));
|
||||
out.extend_from_slice(&put_u32(ifd.order, yd));
|
||||
|
||||
let offset_at: usize = if ifd.big { 8 } else { 4 };
|
||||
if ifd.big {
|
||||
out.get_mut(offset_at..offset_at.checked_add(8)?)?.copy_from_slice(&put_u64(ifd.order, new_ifd as u64));
|
||||
} else {
|
||||
out.get_mut(offset_at..offset_at.checked_add(4)?)?.copy_from_slice(&put_u32(ifd.order, u32::try_from(new_ifd).ok()?));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// One IFD entry. Rational fields store an offset; the SHORT unit is inline.
|
||||
fn resolution_entry(order: Order, big: bool, tag: u16, value: u64) -> Option<Vec<u8>> {
|
||||
let entry_bytes = if big { 20 } else { 12 };
|
||||
let count_at = 4;
|
||||
let value_at = if big { 12 } else { 8 };
|
||||
let mut entry = vec![0; entry_bytes];
|
||||
entry.get_mut(..2)?.copy_from_slice(&put_u16(order, tag));
|
||||
entry.get_mut(2..4)?.copy_from_slice(&put_u16(order, if tag == TAG_RESOLUTION_UNIT { TYPE_SHORT } else { TYPE_RATIONAL }));
|
||||
if big {
|
||||
entry.get_mut(count_at..count_at + 8)?.copy_from_slice(&put_u64(order, 1));
|
||||
if tag == TAG_RESOLUTION_UNIT {
|
||||
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
|
||||
} else {
|
||||
entry.get_mut(value_at..value_at + 8)?.copy_from_slice(&put_u64(order, value));
|
||||
}
|
||||
} else {
|
||||
entry.get_mut(count_at..count_at + 4)?.copy_from_slice(&put_u32(order, 1));
|
||||
if tag == TAG_RESOLUTION_UNIT {
|
||||
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
|
||||
} else {
|
||||
entry.get_mut(value_at..value_at + 4)?.copy_from_slice(&put_u32(order, u32::try_from(value).ok()?));
|
||||
}
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Round `value` up to the next `alignment` boundary, returning `None` on overflow.
|
||||
fn align_up(value: usize, alignment: usize) -> Option<usize> {
|
||||
value.checked_add((alignment - value % alignment) % alignment)
|
||||
}
|
||||
|
||||
fn put_u16(order: Order, v: u16) -> [u8; 2] {
|
||||
match order {
|
||||
Order::Little => v.to_le_bytes(),
|
||||
@@ -407,13 +527,25 @@ fn exif_without_thumbnail(exif: &[u8]) -> Option<Vec<u8>> {
|
||||
/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and,
|
||||
/// when `ppi` is known, the resolution (see [`exif_with_resolution`]).
|
||||
pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
|
||||
export_exif_inner(exif, ppi, false)
|
||||
}
|
||||
|
||||
/// Export EXIF for a format without its own resolution field, adding missing X/Y resolution
|
||||
/// entries to valid metadata when necessary.
|
||||
pub(crate) fn export_exif_adding_missing_resolution(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
|
||||
export_exif_inner(exif, ppi, true)
|
||||
}
|
||||
|
||||
/// Normalize EXIF orientation and thumbnails, optionally adding missing output resolution tags.
|
||||
fn export_exif_inner(exif: &[u8], ppi: Option<(f32, f32)>, add_missing: bool) -> Cow<'_, [u8]> {
|
||||
let up = upright_exif(exif);
|
||||
let up = match exif_without_thumbnail(&up) {
|
||||
Some(v) => Cow::Owned(v),
|
||||
None => up,
|
||||
};
|
||||
let Some(ppi) = ppi else { return up };
|
||||
if let Cow::Owned(v) = exif_with_resolution(&up, ppi) {
|
||||
let result = if add_missing { exif_with_resolution_adding_missing(&up, ppi) } else { exif_with_resolution(&up, ppi) };
|
||||
if let Cow::Owned(v) = result {
|
||||
return Cow::Owned(v);
|
||||
}
|
||||
up
|
||||
|
||||
@@ -213,6 +213,48 @@ pub fn rgb_to_gray(rgb: [f32; 3]) -> f32 {
|
||||
0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
|
||||
}
|
||||
|
||||
/// HSB of an sRGB colour with the hue in sextants (0..6) instead of degrees, and hue 0 for greys.
|
||||
pub fn rgb_to_hsb_sextant(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let (mx, mn) = (rgb[0].max(rgb[1]).max(rgb[2]), rgb[0].min(rgb[1]).min(rgb[2]));
|
||||
let d = mx - mn;
|
||||
let h = if d <= 0.0 {
|
||||
0.0
|
||||
} else if mx == rgb[0] {
|
||||
((rgb[1] - rgb[2]) / d).rem_euclid(6.0)
|
||||
} else if mx == rgb[1] {
|
||||
(rgb[2] - rgb[0]) / d + 2.0
|
||||
} else {
|
||||
(rgb[0] - rgb[1]) / d + 4.0
|
||||
};
|
||||
[h, if mx > 0.0 { d / mx } else { 0.0 }, mx]
|
||||
}
|
||||
|
||||
/// The sRGB colour at a hue sextant (0..6) with the given chroma, raised by offset on every channel.
|
||||
pub fn rgb_from_hue_sextant(sextant: f32, chroma: f32, offset: f32) -> [f32; 3] {
|
||||
let x = chroma * (1.0 - (sextant.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match sextant as u32 {
|
||||
0 => (chroma, x, 0.0),
|
||||
1 => (x, chroma, 0.0),
|
||||
2 => (0.0, chroma, x),
|
||||
3 => (0.0, x, chroma),
|
||||
4 => (x, 0.0, chroma),
|
||||
_ => (chroma, 0.0, x),
|
||||
};
|
||||
[r + offset, g + offset, b + offset]
|
||||
}
|
||||
|
||||
/// HSB to sRGB, the hue in degrees (wrapping) and saturation and brightness in 0..1, as Photoshop's HSB.
|
||||
pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
let chroma = v * s;
|
||||
rgb_from_hue_sextant(h.rem_euclid(360.0) / 60.0, chroma, v - chroma)
|
||||
}
|
||||
|
||||
/// sRGB to HSB, the hue in degrees 0..360 and saturation and brightness in 0..1.
|
||||
pub fn rgb_to_hsb(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let [h, s, v] = rgb_to_hsb_sextant(rgb);
|
||||
[60.0 * h, s, v]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -294,6 +336,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// Lab round-trips sRGB and puts white at L 100 and black at L 0.
|
||||
#[test]
|
||||
fn lab_roundtrip_and_anchors() {
|
||||
let white = srgb_to_lab([1.0, 1.0, 1.0]);
|
||||
@@ -307,4 +350,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HSB hits the primary and grey anchors and round-trips sRGB.
|
||||
#[test]
|
||||
fn hsb_conversions_match_known_colours_and_round_trip() {
|
||||
assert_eq!(hsb_to_rgb([120.0, 1.0, 1.0]), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(hsb_to_rgb([0.0, 0.0, 0.5]), [0.5, 0.5, 0.5]);
|
||||
assert_eq!(rgb_to_hsb([0.0, 0.0, 1.0]), [240.0, 1.0, 1.0]);
|
||||
assert_eq!(rgb_to_hsb([0.25, 0.25, 0.25]), [0.0, 0.0, 0.25]);
|
||||
for r in 0..=8 {
|
||||
for g in 0..=8 {
|
||||
for b in 0..=8 {
|
||||
let c = [r as f32 / 8.0, g as f32 / 8.0, b as f32 / 8.0];
|
||||
let back = hsb_to_rgb(rgb_to_hsb(c));
|
||||
assert!(back.iter().zip(c).all(|(x, y)| (x - y).abs() < 1e-5), "{c:?} -> {back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ fn document_with_strokes(layer_count: usize) -> Result<Document, Box<dyn Error>>
|
||||
size: 6.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)),
|
||||
overprint: false,
|
||||
}));
|
||||
layer.advanced.transparency_shapes = false;
|
||||
document.layers.push(layer);
|
||||
|
||||
@@ -289,6 +289,7 @@ fn inside_stroke() -> Effect {
|
||||
size: 2.0,
|
||||
position: StrokePosition::Inside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
overprint: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -298,6 +299,7 @@ fn outside_stroke() -> Effect {
|
||||
size: 6.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
overprint: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -318,6 +320,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
|
||||
size: 6.0,
|
||||
position,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
overprint: false,
|
||||
})];
|
||||
l.blend = blend;
|
||||
l.fill_opacity = fill;
|
||||
|
||||
@@ -997,7 +997,9 @@ pub fn hash_noise(x: i64, y: i64) -> f32 {
|
||||
}
|
||||
|
||||
/// Effect noise (Photoshop's Noise slider): monochrome speckle in the coverage,
|
||||
/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched.
|
||||
/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched. The speckle is
|
||||
/// scaled by the coverage, so it fades out with the effect instead of lighting up the blur's
|
||||
/// near-zero tail out to the edge of its rectangular reach (#7833).
|
||||
fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) {
|
||||
if !amount.is_finite() || amount <= 0.0 {
|
||||
return;
|
||||
@@ -1009,7 +1011,7 @@ fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) {
|
||||
continue;
|
||||
}
|
||||
let n = hash_noise(i64::from(x0 + x as i32), i64::from(y0 + y as i32));
|
||||
*v = (*v + (n - 0.5) * 2.0 * amount).clamp(0.0, 1.0);
|
||||
*v = (*v * (1.0 + (n - 0.5) * 2.0 * amount)).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2521,6 +2523,7 @@ mod tests {
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: photocraft_doc::FxCommon::new(BlendMode::Screen, 0.75),
|
||||
highlight_color: photocraft_color::Color::WHITE,
|
||||
shadow: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
@@ -3322,6 +3325,56 @@ mod tests {
|
||||
assert!(diff > 5.0, "glow noise changes the coverage: {diff}");
|
||||
}
|
||||
|
||||
/// Issue #7833: the speckle stays inside the glow's visible falloff instead of filling the
|
||||
/// blur's whole rectangular reach out to the map edge.
|
||||
#[test]
|
||||
fn effect_noise_stays_inside_the_visible_falloff() {
|
||||
let mut shape = Map::new(80, 80, 0.0);
|
||||
for y in 35..45 {
|
||||
for x in 35..45 {
|
||||
shape.v[y * 80 + x] = 1.0;
|
||||
}
|
||||
}
|
||||
let g = |noise: f32| Glow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
|
||||
paint: FxPaint::Color(photocraft_color::Color::WHITE),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 10.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise,
|
||||
source: GlowSource::Edge,
|
||||
};
|
||||
let s = |noise: f32| Shadow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0),
|
||||
color: photocraft_color::Color::BLACK,
|
||||
angle: 0.0,
|
||||
use_global_light: false,
|
||||
distance: 0.0,
|
||||
spread: 0.0,
|
||||
size: 10.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
noise,
|
||||
knocks_out: true,
|
||||
};
|
||||
let light = GlobalLight::default();
|
||||
let pairs = [
|
||||
("glow", glow_map(&shape, &g(0.0), false, (0, 0)), glow_map(&shape, &g(0.5), false, (0, 0))),
|
||||
("shadow", shadow_map(&shape, &s(0.0), &light, false, (0, 0)), shadow_map(&shape, &s(0.5), &light, false, (0, 0))),
|
||||
];
|
||||
for (name, plain, noisy) in pairs {
|
||||
for (i, (p, n)) in plain.v.iter().zip(&noisy.v).enumerate() {
|
||||
if *p < 1.0 / 255.0 {
|
||||
assert!(*n <= 2.0 / 255.0, "{name}: pixel ({}, {}) invisible without noise: {p} -> {n}", i % 80, i / 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_by_hostile_distances_does_not_overflow() {
|
||||
let m = square_shape(12);
|
||||
|
||||
+118
-8
@@ -689,11 +689,33 @@ fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<R
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
}
|
||||
for (i, p) in acc.iter_mut().enumerate() {
|
||||
let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h));
|
||||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||||
let a = p[3];
|
||||
*p = if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] };
|
||||
let col_w: Vec<usize> = (0..ow)
|
||||
.map(|x| {
|
||||
let (x0, x1) = span(ox0 + x, fw, w);
|
||||
(x1 - x0).max(1)
|
||||
})
|
||||
.collect();
|
||||
let norm_row = |row: usize, out: &mut [[f32; 4]]| {
|
||||
let (y0, y1) = span(oy0 + row, fh, h);
|
||||
let rh = (y1 - y0).max(1);
|
||||
for (p, &cw) in out.iter_mut().zip(&col_w) {
|
||||
let a = p[3];
|
||||
if a > 0.0 {
|
||||
let n = (rh * cw) as f32;
|
||||
*p = [p[0] / a, p[1] / a, p[2] / a, a / n];
|
||||
} else {
|
||||
*p = [0.0; 4];
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
acc.par_chunks_mut(ow).enumerate().for_each(|(row, out)| norm_row(row, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (row, out) in acc.chunks_mut(ow).enumerate() {
|
||||
norm_row(row, out);
|
||||
}
|
||||
Buffer { rect: out, px: acc }
|
||||
}
|
||||
@@ -1144,9 +1166,7 @@ pub fn relayout_damage(a: &Document, b: &Document) -> Rect {
|
||||
}
|
||||
|
||||
/// The bounding box of a shape layer's path and stroke, ignoring its Density and Feather: its
|
||||
/// path control bounds plus stroke width. It is the frame Free Transform and the Move tool's
|
||||
/// transform controls show for a soft shape, whose rendering reaches past it (in Photoshop a
|
||||
/// shape's transform frame follows its path whatever its mask Density and Feather).
|
||||
/// path control bounds plus stroke width (a conservative reach for any alignment, cap and join).
|
||||
pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
|
||||
if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) {
|
||||
return None;
|
||||
@@ -1157,6 +1177,75 @@ pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The frame Free Transform, the Move tool's transform controls, snapping and Properties show for
|
||||
/// a soft shape (Density below 100 % or a Feather), whose rendering reaches past its path: the
|
||||
/// pixels the same shape renders in `format` over `canvas` at full Density without Feather, so
|
||||
/// the frame is exactly the hard shape's whatever the stroke's alignment, cap and join (in
|
||||
/// Photoshop a shape's transform frame follows its path and stroke whatever its mask Density and
|
||||
/// Feather, #5846). An inverted path's frame is its own outline, not the whole canvas. Renders
|
||||
/// only the strips along the edges of the rasterizers' (1 px conservative) bounds.
|
||||
pub fn soft_shape_frame(sh: &photocraft_doc::ShapeLayer, format: photocraft_color::PixelFormat, canvas: Rect) -> Option<Rect> {
|
||||
let mut path = sh.path.clone();
|
||||
path.inverted = false;
|
||||
let hard = photocraft_doc::ShapeLayer { density: 1.0, feather: 0.0, path, fill: sh.fill.clone(), stroke: sh.stroke.clone(), ..Default::default() };
|
||||
let shape = photocraft_vector::CompiledShape::new(&hard, photocraft_vector::DEFAULT_TOLERANCE, canvas);
|
||||
let r = shape.bounds()?.intersect(&canvas);
|
||||
trim_to_painted(r, |strip| shape.render(format, strip).content_bounds())
|
||||
}
|
||||
|
||||
/// `r` shrunk to the pixels `painted` reports (the painted bounds within a strip of `r`, empty if
|
||||
/// none), looking in from each edge through strips that double in thickness: a few renders per
|
||||
/// edge however wide the empty margin (a subtracted or intersected subpath can leave most of the
|
||||
/// rasterizers' bounds empty), and none of `r`'s inside rendered at once.
|
||||
fn trim_to_painted(mut r: Rect, mut painted: impl FnMut(Rect) -> Rect) -> Option<Rect> {
|
||||
// Top: the first strip holding paint gives the first painted row.
|
||||
let mut t = 1;
|
||||
while !r.is_empty() {
|
||||
let strip = Rect::new(r.x0, r.y0, r.x1, r.y0.saturating_add(t).min(r.y1));
|
||||
let p = painted(strip).intersect(&strip);
|
||||
if !p.is_empty() {
|
||||
r.y0 = p.y0;
|
||||
break;
|
||||
}
|
||||
r.y0 = strip.y1;
|
||||
t = t.saturating_mul(2);
|
||||
}
|
||||
let mut t = 1;
|
||||
while !r.is_empty() {
|
||||
let strip = Rect::new(r.x0, r.y1.saturating_sub(t).max(r.y0), r.x1, r.y1);
|
||||
let p = painted(strip).intersect(&strip);
|
||||
if !p.is_empty() {
|
||||
r.y1 = p.y1;
|
||||
break;
|
||||
}
|
||||
r.y1 = strip.y0;
|
||||
t = t.saturating_mul(2);
|
||||
}
|
||||
let mut t = 1;
|
||||
while !r.is_empty() {
|
||||
let strip = Rect::new(r.x0, r.y0, r.x0.saturating_add(t).min(r.x1), r.y1);
|
||||
let p = painted(strip).intersect(&strip);
|
||||
if !p.is_empty() {
|
||||
r.x0 = p.x0;
|
||||
break;
|
||||
}
|
||||
r.x0 = strip.x1;
|
||||
t = t.saturating_mul(2);
|
||||
}
|
||||
let mut t = 1;
|
||||
while !r.is_empty() {
|
||||
let strip = Rect::new(r.x1.saturating_sub(t).max(r.x0), r.y0, r.x1, r.y1);
|
||||
let p = painted(strip).intersect(&strip);
|
||||
if !p.is_empty() {
|
||||
r.x1 = p.x1;
|
||||
break;
|
||||
}
|
||||
r.x1 = strip.x0;
|
||||
t = t.saturating_mul(2);
|
||||
}
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width
|
||||
/// (and the reach of a soft fill).
|
||||
pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
|
||||
@@ -1840,6 +1929,27 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
|
||||
return;
|
||||
}
|
||||
if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_))
|
||||
&& let Some(s) = layer.surface()
|
||||
&& layer.blend == BlendMode::Normal
|
||||
&& opacity >= 1.0
|
||||
&& !clipped.iter().any(|c| c.visible)
|
||||
&& layer.mask.is_none()
|
||||
&& layer.vector_mask.is_none()
|
||||
&& text_gamma(layer) == 1.0
|
||||
&& !psblend::LAB_MIX.with(|l| l.get())
|
||||
&& backdrop.px.iter().all(|p| p[3] <= 0.0)
|
||||
{
|
||||
s.read_rgba_into(rect, &mut backdrop.px);
|
||||
for p in &mut backdrop.px {
|
||||
if p[3] <= 0.0 {
|
||||
*p = [0.0; 4];
|
||||
} else if p[3] < 1.0 {
|
||||
*p = psblend::composite_normal_rgb([0.0; 4], *p, 1.0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if clipped.is_empty()
|
||||
&& let Some(surf) = pixel_surface(layer)
|
||||
{
|
||||
|
||||
@@ -58,8 +58,9 @@ fn feather_matches_photoshops_ramp() {
|
||||
let r = row(&shape_doc(depth, 1.0, 10.0, None), 100);
|
||||
let ours: Vec<i32> = (0..15).map(|i| r[30 + 3 * i]).collect();
|
||||
let dev = ours.iter().zip(PS_FEATHER_10).map(|(a, b)| (a - i32::from(b)).abs()).max().unwrap_or(0);
|
||||
// Same Gaussian (sigma = feather) as a vector mask: within 3 levels of Photoshop.
|
||||
assert!(dev <= 3, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})");
|
||||
// Same Gaussian (sigma = feather) as a vector mask: within 1 level of Photoshop (as
|
||||
// scorecard TOOL-214-12 claims; measured max deviation 1 at every depth).
|
||||
assert!(dev <= 1, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})");
|
||||
eprintln!("{depth:?}: ours {ours:?}, Photoshop {PS_FEATHER_10:?}, max deviation {dev}");
|
||||
// The path runs between pixels 49 and 50: their mean is half way.
|
||||
assert!((r[49] + r[50] - 255).abs() <= 2, "{depth:?}: half way on the path: {} {}", r[49], r[50]);
|
||||
@@ -157,3 +158,42 @@ fn soft_shape_bounds_cover_the_fill() {
|
||||
assert_eq!(layer_bounds(&dense.layers[1], canvas), canvas);
|
||||
assert!(effect_outline(&dense.layers[1]).is_none(), "a soft fill's alpha is its outline");
|
||||
}
|
||||
|
||||
/// A soft shape's frame is the hard shape's painted pixels, also where a subtracted subpath
|
||||
/// leaves most of the rasterizers' bounds empty (#5846), at every depth.
|
||||
#[test]
|
||||
fn soft_shape_frame_skips_a_subtracted_margin() {
|
||||
let canvas = Rect::new(0, 0, 200, 200);
|
||||
let mut cut = Subpath::polygon(&[(40.0, 40.0), (160.0, 40.0), (160.0, 140.0), (40.0, 140.0)]);
|
||||
cut.op = photocraft_doc::vector::PathOp::Subtract;
|
||||
let path = Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)]), cut]);
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let sh = ShapeLayer { path: path.clone(), fill: Some(Fill::Solid(Color::BLACK)), density: 0.5, feather: 4.0, ..ShapeLayer::default() };
|
||||
assert_eq!(soft_shape_frame(&sh, doc(depth).pixel_format(), canvas), Some(Rect::new(50, 140, 150, 150)), "{depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame scan renders a few strips per edge, not one per empty row or column: a 2-px
|
||||
/// painted spot in a 4000 × 3000 bounds takes a handful of renders and is found exactly.
|
||||
#[test]
|
||||
fn soft_shape_frame_scan_is_bounded() {
|
||||
let spot = Rect::new(2500, 1700, 2502, 1702);
|
||||
let mut calls = 0;
|
||||
let got = trim_to_painted(Rect::new(0, 0, 4000, 3000), |strip| {
|
||||
calls += 1;
|
||||
strip.intersect(&spot)
|
||||
});
|
||||
assert_eq!(got, Some(spot));
|
||||
assert!(calls <= 4 * 14, "{calls} renders");
|
||||
let mut calls = 0;
|
||||
assert_eq!(
|
||||
trim_to_painted(Rect::new(0, 0, 4000, 3000), |_| {
|
||||
calls += 1;
|
||||
Rect::EMPTY
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert!(calls <= 14, "an empty shape stops after the top edge: {calls} renders");
|
||||
assert_eq!(trim_to_painted(Rect::EMPTY, |r| r), None);
|
||||
assert_eq!(trim_to_painted(Rect::new(3, 4, 9, 7), |r| r), Some(Rect::new(3, 4, 9, 7)), "a full rect keeps its edges");
|
||||
}
|
||||
@@ -21,7 +21,7 @@ fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
|
||||
}
|
||||
|
||||
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect {
|
||||
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint })
|
||||
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint, overprint: false })
|
||||
}
|
||||
|
||||
fn blue() -> FxPaint {
|
||||
|
||||
@@ -1172,6 +1172,7 @@ fn stroke(size: f32, position: StrokePosition) -> Effect {
|
||||
size,
|
||||
position,
|
||||
paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)),
|
||||
overprint: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1448,6 +1449,7 @@ fn satin_and_bevel_stay_inside_shape() {
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: photocraft_doc::Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
|
||||
@@ -1624,6 +1626,7 @@ fn bevel_fx(style: photocraft_doc::BevelStyle, soften: f32) -> Effect {
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: photocraft_doc::Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
|
||||
@@ -1864,6 +1867,7 @@ fn emboss_shades_the_composited_layer() {
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: photocraft_doc::Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
|
||||
@@ -1913,6 +1917,7 @@ fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() {
|
||||
size: 3.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
|
||||
overprint: false,
|
||||
});
|
||||
let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]);
|
||||
assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20));
|
||||
@@ -2843,6 +2848,7 @@ fn nested_parallel_fx_doc() -> Document {
|
||||
size: 3.0,
|
||||
position: StrokePosition::Inside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
overprint: false,
|
||||
})];
|
||||
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
|
||||
glow.opacity = 0.5;
|
||||
@@ -2873,6 +2879,7 @@ fn nested_parallel_fx_doc() -> Document {
|
||||
altitude: 32.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: FxCommon::new(BlendMode::Screen, 0.75),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
|
||||
@@ -166,6 +166,9 @@ pub struct StrokeFx {
|
||||
pub size: f32,
|
||||
pub position: StrokePosition,
|
||||
pub paint: FxPaint,
|
||||
/// Overprint (PSD `overprint`).
|
||||
#[serde(default)]
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -213,6 +216,9 @@ pub struct Bevel {
|
||||
pub altitude: f32,
|
||||
pub use_global_light: bool,
|
||||
pub gloss_contour: Contour,
|
||||
/// Gloss Contour › Anti-aliased (PSD `antialiasGloss`).
|
||||
#[serde(default)]
|
||||
pub gloss_anti_alias: bool,
|
||||
pub highlight: FxCommon,
|
||||
pub highlight_color: Color,
|
||||
pub shadow: FxCommon,
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct ActionState {
|
||||
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
|
||||
#[serde(skip)]
|
||||
pub rev: u64,
|
||||
/// Bumped by each `actions.record`, so a background job knows whether the recording it was
|
||||
/// started in is still the one running when its result lands.
|
||||
#[serde(skip)]
|
||||
pub take: u64,
|
||||
}
|
||||
|
||||
impl ActionState {
|
||||
@@ -121,11 +125,22 @@ pub fn replayable(id: &str) -> bool {
|
||||
&& (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal)))
|
||||
}
|
||||
|
||||
/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path
|
||||
/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder
|
||||
/// instead.
|
||||
/// File › Save, Save As and Save a Copy, and File › Export › Export As, recorded by the desktop
|
||||
/// shell once the file has a path (#2032, #4373, #7207). The shell replays them; batch and
|
||||
/// droplets write to their destination folder instead.
|
||||
pub fn shell_save_command(id: &str) -> bool {
|
||||
matches!(id, "file.save" | "file.saveAs" | "file.saveACopy")
|
||||
matches!(id, "file.save" | "file.saveAs" | "file.saveACopy" | "file.export.exportAs")
|
||||
}
|
||||
|
||||
/// The failure for a played Save step whose document `target` (the one active when the step
|
||||
/// started) is no longer open: a Save Document script event closed it. Playback stops there, so
|
||||
/// the next step without a document, such as the Close half of an imported Close with Save: Yes,
|
||||
/// doesn't act on whichever document is active now (#7367).
|
||||
pub fn save_target_closed(s: &Session, id: &str, target: Option<photocraft_doc::DocId>) -> Option<String> {
|
||||
let target = target.filter(|_| shell_save_command(id))?;
|
||||
(!s.documents().iter().any(|d| d.doc.id == target)).then(|| {
|
||||
"the document was closed during the save (by a Save Document script event); playback stopped so later steps don't act on another document".into()
|
||||
})
|
||||
}
|
||||
|
||||
/// View-only menu commands recorded by the desktop shell. Headless playback reports
|
||||
@@ -287,6 +302,7 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.actions.list.len() - 1
|
||||
};
|
||||
s.actions.recording = Some((s.journal.len(), idx));
|
||||
s.actions.take = s.actions.take.wrapping_add(1);
|
||||
let name = s.actions.list.get(idx).map(|a| a.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"action": name, "index": idx}))
|
||||
}
|
||||
@@ -322,6 +338,12 @@ fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"action": action.name, "steps": action.steps.len()}))
|
||||
}
|
||||
|
||||
/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an
|
||||
/// open document and nothing selected (actions often start with it to clear any selection, #6744).
|
||||
fn noop_step(s: &Session, id: &str) -> bool {
|
||||
id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none())
|
||||
}
|
||||
|
||||
fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<Value>) {
|
||||
let mut ran = 0u64;
|
||||
let mut failed = None;
|
||||
@@ -332,6 +354,10 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
|
||||
failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
if noop_step(s, id) {
|
||||
ran += 1;
|
||||
continue;
|
||||
}
|
||||
match s.execute(id, params.clone()) {
|
||||
Ok(value) => {
|
||||
if let Some(error) = nested_failure(id, &value) {
|
||||
@@ -722,6 +748,27 @@ mod tests {
|
||||
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_with_nothing_selected_is_a_noop_in_playback() {
|
||||
// #6744: imported actions often start with Deselect; with no selection Photoshop plays on.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
// Outside playback the command stays disabled, like Photoshop's greyed menu item.
|
||||
assert!(s.execute("select.deselect", json!({})).is_err());
|
||||
assert!(s.active().unwrap().doc.selection.is_none());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before, "rejection adds no history step");
|
||||
let deselect = crate::commands::find("select.deselect").unwrap();
|
||||
assert_eq!((deselect.run)(&mut s, &json!({})).unwrap(), Value::Null);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before, "no history step");
|
||||
|
||||
let steps = vec![("select.deselect".to_string(), json!({})), ("layer.new.layer".to_string(), json!({"name": "A"}))];
|
||||
s.actions.list.push(Action::new("Clear", steps));
|
||||
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert_eq!(played["ran"], 2, "{played}");
|
||||
assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -261,6 +261,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let label = "Auto-Blend Layers";
|
||||
let (new_layer, depth_layer) = s.edit(label, |doc, active| {
|
||||
for (id, wt) in ids.iter().zip(&weights) {
|
||||
// The Background can't have a mask: it becomes a normal layer first, as in Photoshop.
|
||||
crate::extra_cmds::background_to_layer_for_mask(doc, *id);
|
||||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut mask = LayerMask::hide_all();
|
||||
mask.surface.write_region(area, wt);
|
||||
@@ -452,6 +454,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5745: the Background can't take a mask, so Auto-Blend turns it into a normal layer first.
|
||||
#[test]
|
||||
fn auto_blend_turns_the_background_into_a_layer() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 60, "background": "white"})).unwrap();
|
||||
let bg = s.active().unwrap().doc.background().unwrap().id;
|
||||
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
|
||||
s.edit("r", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(70, 0, 200, 60), &[0.9, 0.6, 0.2, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let right = s.active().unwrap().active_layer.unwrap();
|
||||
select(&mut s, &[bg, right]);
|
||||
s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert!(!doc.is_background(bg), "the Background became a layer to take its mask");
|
||||
assert!(doc.layer(bg).unwrap().mask.is_some());
|
||||
s.undo();
|
||||
assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_blend_stack_without_seamless() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{EngineError, Result};
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static FAIL_NEXT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
static FAIL_AFTER: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Reserve and initialize a buffer without invoking the allocator's abort-on-OOM path.
|
||||
@@ -18,7 +19,7 @@ pub(crate) fn filled<T: Clone>(len: usize, value: T, purpose: &str) -> Result<Ve
|
||||
pub(crate) fn capacity<T>(len: usize, purpose: &str) -> Result<Vec<T>> {
|
||||
let bytes = len.checked_mul(std::mem::size_of::<T>()).ok_or_else(|| error(purpose, "requested size overflowed"))?;
|
||||
#[cfg(test)]
|
||||
if FAIL_NEXT.with(|flag| flag.replace(false)) {
|
||||
if failpoint_hits() {
|
||||
return Err(error(purpose, "not enough memory"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
@@ -30,7 +31,7 @@ pub(crate) fn capacity<T>(len: usize, purpose: &str) -> Result<Vec<T>> {
|
||||
/// The test-only failpoint lets command tests prove their transaction boundary.
|
||||
pub(crate) fn checkpoint(purpose: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
if FAIL_NEXT.with(|flag| flag.replace(false)) {
|
||||
if failpoint_hits() {
|
||||
return Err(error(purpose, "not enough memory"));
|
||||
}
|
||||
let _ = purpose;
|
||||
@@ -57,6 +58,34 @@ pub(crate) fn fail_next_for_test() {
|
||||
FAIL_NEXT.with(|flag| flag.set(true));
|
||||
}
|
||||
|
||||
/// Fail the fallible allocation or checkpoint after `skip` clean attempts, then stop.
|
||||
/// `fail_after_for_test(0)` is equivalent to [`fail_next_for_test`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_after_for_test(skip: usize) {
|
||||
FAIL_AFTER.with(|flag| flag.set(Some(skip)));
|
||||
}
|
||||
|
||||
/// Whether a test failpoint trips here: the single-shot flag, or the counted flag
|
||||
/// once its `skip` attempts have passed cleanly earlier. The shot does not advance the
|
||||
/// counted flag: a one-shot failure is not itself a clean attempt.
|
||||
#[cfg(test)]
|
||||
fn failpoint_hits() -> bool {
|
||||
if FAIL_NEXT.with(|flag| flag.replace(false)) {
|
||||
return true;
|
||||
}
|
||||
FAIL_AFTER.with(|flag| match flag.get() {
|
||||
Some(0) => {
|
||||
flag.set(None);
|
||||
true
|
||||
}
|
||||
Some(rest) => {
|
||||
flag.set(Some(rest - 1));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -67,4 +96,27 @@ mod tests {
|
||||
let result = filled::<u8>(16, 0, "test image");
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for test image")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_after_trips_only_on_the_counted_attempt() {
|
||||
fail_after_for_test(2);
|
||||
assert!(filled::<u8>(4, 0, "one").is_ok(), "the counted failpoint lets the first attempts pass cleanly");
|
||||
assert!(filled::<u8>(4, 0, "two").is_ok());
|
||||
assert!(filled::<u8>(4, 0, "three").is_err_and(|e| e.to_string().contains("not enough memory for three")));
|
||||
// The flag is consumed by the trip: later attempts are clean again.
|
||||
assert!(filled::<u8>(4, 0, "four").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_one_shot_does_not_spend_the_counted_failpoint() {
|
||||
// One-shot and counted flags both armed: the shot trips the first attempt, and that
|
||||
// failure is not a clean attempt, so the counter still has one to go past (review note
|
||||
// on #7353). Afterwards the counted attempt trips as usual.
|
||||
fail_after_for_test(1);
|
||||
fail_next_for_test();
|
||||
assert!(filled::<u8>(4, 0, "shot").is_err_and(|e| e.to_string().contains("not enough memory for shot")));
|
||||
assert!(filled::<u8>(4, 0, "skipped").is_ok(), "the counted attempt has not run out of clean attempts");
|
||||
assert!(filled::<u8>(4, 0, "counted").is_err_and(|e| e.to_string().contains("not enough memory for counted")));
|
||||
assert!(filled::<u8>(4, 0, "done").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32;
|
||||
let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" };
|
||||
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
|
||||
let (nid, rect) = s.edit(label, |doc, active| {
|
||||
if (sx, sy) != (0, 0) {
|
||||
crate::image_cmds::translate_doc(doc, cmd, sx, sy)?;
|
||||
@@ -440,7 +441,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let nid = if duplicate {
|
||||
let src_rect = doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?;
|
||||
let (dx, dy) = (rect.x0.saturating_sub(src_rect.x0), rect.y0.saturating_sub(src_rect.y0));
|
||||
let copy = crate::commands::layer_copy(doc, id)?;
|
||||
let copy = crate::commands::layer_copy(doc, id, add_copy)?;
|
||||
let nid = doc.insert_above(Some(id), copy);
|
||||
// Grow the canvas first, so shapes on the board re-render uncut where they land.
|
||||
let fit = |len: u32, edge: i32| i64::from(len).max(i64::from(edge)).clamp(1, 300_000) as u32;
|
||||
|
||||
@@ -612,3 +612,24 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() {
|
||||
assert_eq!(doc(&s).size, before);
|
||||
assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err());
|
||||
}
|
||||
|
||||
/// Adding a duplicated artboard beside another appends "copy" to its name only while the Layers panel option is on.
|
||||
#[test]
|
||||
fn add_beside_duplicate_honors_panel_copy_name_preference() {
|
||||
for add_copy in [false, true] {
|
||||
let mut s = session(8);
|
||||
let id = board_with_square(&mut s);
|
||||
let name = doc(&s).layer(id).unwrap().name.clone();
|
||||
s.edit_prefs(|prefs| {
|
||||
prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"addCopyToCopiedLayersAndGroups": add_copy}));
|
||||
});
|
||||
let before = doc(&s).clone();
|
||||
let result = s.execute("layer.artboard.addBeside", json!({"layer": id.0, "side": "bottom", "duplicate": true})).unwrap();
|
||||
let copy = LayerId(result["layer"].as_u64().unwrap());
|
||||
let expected = if add_copy { before.copy_name(&name) } else { name };
|
||||
assert_eq!(doc(&s).layer(copy).unwrap().name, expected);
|
||||
assert!(red_at(&s, 15, 155), "the copied artboard retains its contents");
|
||||
s.undo();
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
}
|
||||
+450
-80
@@ -121,6 +121,7 @@ enum Target {
|
||||
Property(String),
|
||||
Class,
|
||||
Offset(i32),
|
||||
Index(u32),
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ fn target(r: &[ReferenceItem]) -> Target {
|
||||
Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)),
|
||||
Some(ReferenceItem::Class(_)) => Target::Class,
|
||||
Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset),
|
||||
Some(ReferenceItem::Index { index, .. }) => Target::Index(*index),
|
||||
_ => Target::Other,
|
||||
}
|
||||
}
|
||||
@@ -389,6 +391,8 @@ enum Kind {
|
||||
Pct,
|
||||
Ang,
|
||||
Long,
|
||||
/// An integer field whose value must survive conversion without rounding or saturation.
|
||||
ExactLong,
|
||||
Dbl,
|
||||
Bool,
|
||||
/// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs.
|
||||
@@ -402,11 +406,13 @@ enum Kind {
|
||||
/// One descriptor key ↔ one command param.
|
||||
struct F(&'static str, &'static str, Kind);
|
||||
|
||||
/// Import mapped descriptor fields, leaving absent values to command defaults.
|
||||
/// Return `None` when a present enum, blend mode or colour cannot be represented.
|
||||
fn fields_from(d: &Descriptor, fields: &[F]) -> Option<Map<String, Value>> {
|
||||
let mut m = Map::new();
|
||||
for F(key, param, kind) in fields {
|
||||
let v = match kind {
|
||||
Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from),
|
||||
Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::ExactLong | Kind::Dbl => get_num(d, key).map(Value::from),
|
||||
Kind::Bool => get_bool(d, key).map(Value::from),
|
||||
Kind::Enum(_, table) => match get_enum(d, key) {
|
||||
Some(e) => Some(Value::from(lookup(table, &e)?)),
|
||||
@@ -428,6 +434,8 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option<Map<String, Value>> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Export supplied mapped params, omitting absent values to preserve event defaults.
|
||||
/// Return `None` for unsupported values; `ExactLong` also rejects rounding or saturation.
|
||||
fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option<Descriptor> {
|
||||
for F(key, param, kind) in fields {
|
||||
let Some(v) = p.get(param) else { continue };
|
||||
@@ -436,6 +444,14 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option<Descriptor> {
|
||||
Kind::Pct => pct(v.as_f64()?),
|
||||
Kind::Ang => ang(v.as_f64()?),
|
||||
Kind::Long => long(v.as_f64()?),
|
||||
Kind::ExactLong => {
|
||||
let number = v.as_f64()?;
|
||||
let integer = number as i32;
|
||||
if f64::from(integer) != number {
|
||||
return None;
|
||||
}
|
||||
D::Integer(integer)
|
||||
}
|
||||
Kind::Dbl => D::Double(v.as_f64()?),
|
||||
Kind::Bool => D::Boolean(v.as_bool()?),
|
||||
Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?),
|
||||
@@ -612,19 +628,22 @@ fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option<Descriptor> {
|
||||
Some(e)
|
||||
}
|
||||
|
||||
// Exporters leave out a param the step doesn't set only where Photoshop plays the missing key
|
||||
// as PhotoCraft's default (checked against Photoshop: Fill opacity and mode, Crop delete,
|
||||
// Selective Color method, Channel Mixer monochrome, Paint Bucket contiguous; Gradient Map
|
||||
// reverse and dither, which Photoshop itself leaves out when off). Elsewhere they
|
||||
// write PhotoCraft's default, as Photoshop plays a missing key differently (Color Balance
|
||||
// preserve luminosity off, Canvas Size anchored top left, Apply Image Normal) or refuses the
|
||||
// step (Paint Bucket without a tolerance).
|
||||
fn hue_sat_to(p: &Value) -> Option<Descriptor> {
|
||||
let d = desc("HStr").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false)));
|
||||
let mut entries = vec![D::Descriptor(hue_entry(p, None)?)];
|
||||
for (i, name) in HUE_RANGES.iter().enumerate() {
|
||||
if let Some(r) = p.get(*name).filter(|v| v.is_object()) {
|
||||
entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?));
|
||||
}
|
||||
}
|
||||
Some(
|
||||
desc("HStr")
|
||||
.with("presetKind", enm("presetKindType", "presetKindCustom"))
|
||||
.with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false)))
|
||||
.with("Adjs", D::List(entries)),
|
||||
)
|
||||
Some(d.with("Adjs", D::List(entries)))
|
||||
}
|
||||
|
||||
fn triple(d: &Descriptor, k: &str) -> Option<Value> {
|
||||
@@ -694,12 +713,17 @@ fn channel_mixer_to(p: &Value) -> Option<Descriptor> {
|
||||
d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3)))));
|
||||
}
|
||||
}
|
||||
Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false))))
|
||||
if let Some(m) = jbool(p, "monochrome") {
|
||||
d = d.with("Mnch", D::Boolean(m));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Photoshop's `Clrs` colour-range enumeration. Yellows is `Ylws` (`Yllw` is the yellow ink
|
||||
/// key); earlier PhotoCraft builds wrote `Yllw`, which import still reads (#6745).
|
||||
const SELECTIVE_RANGES: &[(&str, &str)] = &[
|
||||
("Rds", "reds"),
|
||||
("Yllw", "yellows"),
|
||||
("Ylws", "yellows"),
|
||||
("Grns", "greens"),
|
||||
("Cyns", "cyans"),
|
||||
("Bls", "blues"),
|
||||
@@ -716,7 +740,8 @@ fn selective_from(d: &Descriptor) -> Option<Value> {
|
||||
}
|
||||
for e in get_list(d, "ClrC").unwrap_or(&[]) {
|
||||
let D::Descriptor(e) = e else { return None };
|
||||
let name = lookup(SELECTIVE_RANGES, &get_enum(e, "Clrs")?)?;
|
||||
let range = get_enum(e, "Clrs")?;
|
||||
let name = lookup(SELECTIVE_RANGES, if range == "Yllw" { "Ylws" } else { &range })?;
|
||||
let v = |k: &str| get_num(e, k).unwrap_or(0.0);
|
||||
out.insert(name.into(), json!([v("Cyn "), v("Mgnt"), v("Yllw"), v("Blck")]));
|
||||
}
|
||||
@@ -724,21 +749,46 @@ fn selective_from(d: &Descriptor) -> Option<Value> {
|
||||
}
|
||||
|
||||
fn selective_to(p: &Value) -> Option<Descriptor> {
|
||||
let mut list = Vec::new();
|
||||
for (ps, name) in SELECTIVE_RANGES {
|
||||
// Each range, as the command reads it (`adjust_cmds::selective_from_params`): the array form
|
||||
// `"reds": [c, m, y, k]` first, then the scalar form, which names one range in `"colors"` and
|
||||
// gives the channels it changes. Writing only the arrays dropped the scalar form's values, and
|
||||
// the step exported as an adjustment that changes nothing (#7672).
|
||||
let mut rows: Vec<Option<[f64; 4]>> = vec![None; SELECTIVE_RANGES.len()];
|
||||
for (row, (_, name)) in rows.iter_mut().zip(SELECTIVE_RANGES) {
|
||||
if let Some(a) = p.get(*name).and_then(Value::as_array) {
|
||||
let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
*row = Some([v(0), v(1), v(2), v(3)]);
|
||||
}
|
||||
}
|
||||
if let Some(named) = jstr(p, "colors") {
|
||||
let i = SELECTIVE_RANGES.iter().position(|(_, name)| *name == named)?;
|
||||
let row = rows.get_mut(i)?;
|
||||
let mut v = row.unwrap_or([0.0; 4]);
|
||||
for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) {
|
||||
// A channel that is given but is not a number is an error in the command
|
||||
// (`adjust_cmds::selective_from_params`), so a step carrying one cannot be written
|
||||
// either: ignoring it would export a different adjustment than the one recorded.
|
||||
// `null` counts as absent there, and so here.
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(_) => *slot = jnum(p, key)?,
|
||||
}
|
||||
}
|
||||
*row = Some(v);
|
||||
}
|
||||
let mut list = Vec::new();
|
||||
for (row, (ps, _)) in rows.iter().zip(SELECTIVE_RANGES) {
|
||||
if let Some(v) = row {
|
||||
list.push(D::Descriptor(
|
||||
desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v(0))).with("Mgnt", pct(v(1))).with("Yllw", pct(v(2))).with("Blck", pct(v(3))),
|
||||
desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v[0])).with("Mgnt", pct(v[1])).with("Yllw", pct(v[2])).with("Blck", pct(v[3])),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(
|
||||
desc("SlcC")
|
||||
.with("presetKind", enm("presetKindType", "presetKindCustom"))
|
||||
.with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" }))
|
||||
.with("ClrC", D::List(list)),
|
||||
)
|
||||
let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom"));
|
||||
if let Some(m) = jstr(p, "method") {
|
||||
d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" }));
|
||||
}
|
||||
Some(d.with("ClrC", D::List(list)))
|
||||
}
|
||||
|
||||
/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`.
|
||||
@@ -787,12 +837,14 @@ fn gradient_map_to(p: &Value) -> Option<Descriptor> {
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(stops?))
|
||||
.with("Trns", transparency);
|
||||
Some(
|
||||
desc("GdMp")
|
||||
.with("Grad", D::Descriptor(grad))
|
||||
.with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false)))
|
||||
.with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))),
|
||||
)
|
||||
let mut d = desc("GdMp").with("Grad", D::Descriptor(grad));
|
||||
if let Some(r) = jbool(p, "reverse") {
|
||||
d = d.with("Rvrs", D::Boolean(r));
|
||||
}
|
||||
if let Some(dd) = jbool(p, "dither") {
|
||||
d = d.with("Dthr", D::Boolean(dd));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), and
|
||||
@@ -821,7 +873,7 @@ const ADJUSTMENTS: &[Adjustment] = &[
|
||||
Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to },
|
||||
adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]),
|
||||
adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]),
|
||||
adj_fields!("PhtF", "photoFilter", &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]),
|
||||
Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to },
|
||||
adj_fields!("BlnW", "blackWhite", BW),
|
||||
Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to },
|
||||
Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to },
|
||||
@@ -832,6 +884,29 @@ fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> {
|
||||
ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c.trim_end())
|
||||
}
|
||||
|
||||
const PHOTO_FILTER_FIELDS: &[F] = &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)];
|
||||
|
||||
/// A `PhtF` step whose params carry a `filter` preset without an explicit `color`: the descriptor
|
||||
/// form is colour-only (Photoshop stores no preset key — the `phfl` notes in `crates/io` conclude
|
||||
/// the same from its CS2 oracles), so the resolved colour must be written, or re-imported playback
|
||||
/// falls back to the default warming filter. With both present, the explicit colour wins, as when
|
||||
/// running the command. An unknown preset id keeps the old export (no colour) instead of failing.
|
||||
fn photo_filter_to(p: &Value) -> Option<Descriptor> {
|
||||
let mut p = p.clone();
|
||||
if p.get("color").is_none() {
|
||||
let hex = p.get("filter").and_then(Value::as_str).and_then(preset_color);
|
||||
if let (Some(hex), Some(o)) = (hex, p.as_object_mut()) {
|
||||
o.insert("color".into(), hex.into());
|
||||
}
|
||||
}
|
||||
fields_to(&p, PHOTO_FILTER_FIELDS, desc("PhtF"))
|
||||
}
|
||||
|
||||
/// The preset id's resolved `#rrggbb`, or none (no id given, or an unknown one).
|
||||
fn preset_color(id: &str) -> Option<String> {
|
||||
crate::adjust_params::PHOTO_FILTERS.iter().find(|f| f.0 == id).map(|(_, _, rgb)| format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))
|
||||
}
|
||||
|
||||
fn adjustment_by_suffix(suffix: &str) -> Option<&'static Adjustment> {
|
||||
ADJUSTMENTS.iter().find(|a| a.suffix == suffix)
|
||||
}
|
||||
@@ -872,6 +947,11 @@ struct Event {
|
||||
|
||||
macro_rules! ev {
|
||||
($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr) => {
|
||||
ev!($code, $name, $label, $id, $fields, unless | _ | false)
|
||||
};
|
||||
// `unless`: params the descriptor can't carry, so such a step isn't exported (it would replay
|
||||
// differently) rather than losing them.
|
||||
($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr, unless $skip:expr) => {
|
||||
Event {
|
||||
code: $code,
|
||||
name: $name,
|
||||
@@ -881,11 +961,18 @@ macro_rules! ev {
|
||||
Some(m) => step($id, Value::Object(m)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None },
|
||||
to: |_, p| if pixel_target(p) && !($skip)(p) { fields_to(p, $fields, desc("null")) } else { None },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Minimum / Maximum with Preserve: Roundness. The descriptor here maps the radius only, and
|
||||
/// Photoshop's encoding of the shape isn't verified, so such a step is not exported: it would
|
||||
/// replay as the default Squareness (#7233).
|
||||
fn preserves_roundness(p: &Value) -> bool {
|
||||
p.get("preserve").and_then(Value::as_str) == Some("roundness")
|
||||
}
|
||||
|
||||
/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's
|
||||
/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such
|
||||
/// a step can't be exported faithfully.
|
||||
@@ -992,6 +1079,14 @@ fn pattern_desc(p: &Value) -> Option<Descriptor> {
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command.
|
||||
const MASK_USING: &[(&str, &str)] = &[
|
||||
("RvlA", "layer.layerMask.revealAll"),
|
||||
("HdAl", "layer.layerMask.hideAll"),
|
||||
("RvlS", "layer.layerMask.revealSelection"),
|
||||
("HdSl", "layer.layerMask.hideSelection"),
|
||||
];
|
||||
|
||||
fn make_from(d: &Descriptor) -> Mapped {
|
||||
// Duplicate layer/group into a new document: `Mk` with a document class, a layer
|
||||
// `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN`
|
||||
@@ -1013,13 +1108,25 @@ fn make_from(d: &Descriptor) -> Mapped {
|
||||
}
|
||||
return step("layer.duplicate", Value::Object(p));
|
||||
}
|
||||
// Add Layer Mask: a `Chnl` class made at the mask channel, `Usng` what it reveals.
|
||||
if let Some(D::Class(c)) = d.get("Nw ")
|
||||
&& trimmed(&c.class_id) == "Chnl"
|
||||
{
|
||||
// The target layer's mask only: a further item (a `Lyr` by name…) puts it on another layer.
|
||||
let at_mask = get_ref(d, "At ")
|
||||
.is_some_and(|r| r.len() == 1 && ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk"));
|
||||
return match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) {
|
||||
Some(id) if at_mask => none(id),
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
}
|
||||
if let Some(nw) = get_obj(d, "Nw ") {
|
||||
// Layer via Copy / Cut (`Usng` = the selection), or a new document.
|
||||
if get_enum(d, "Usng").as_deref() == Some("Slct") {
|
||||
let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" };
|
||||
let mut v = vec![(id, json!({}))];
|
||||
if let Some(n) = get_text(nw, "Nm ") {
|
||||
v.push(("layer.renameLayer", json!({"name": n})));
|
||||
v.push(("layer.setProps", json!({"name": n})));
|
||||
}
|
||||
return steps(v);
|
||||
}
|
||||
@@ -1162,6 +1269,14 @@ fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
}
|
||||
d
|
||||
}
|
||||
"layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection" => {
|
||||
// Photoshop adds the mask to the target layer only.
|
||||
if p.get("layer").is_some() {
|
||||
return None;
|
||||
}
|
||||
let at = D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]);
|
||||
desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?))
|
||||
}
|
||||
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
|
||||
"file.new" => {
|
||||
let mut n = desc("Dcmn");
|
||||
@@ -1272,6 +1387,11 @@ fn set_from(d: &Descriptor) -> Mapped {
|
||||
}
|
||||
steps(v)
|
||||
}
|
||||
// The toolbox colours.
|
||||
(Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) {
|
||||
Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})),
|
||||
None => unsupported,
|
||||
},
|
||||
(Some("Lyr"), _) => {
|
||||
let Some(mut p) = layer_target(r) else { return unsupported };
|
||||
let Some(t) = get_obj(d, "T ") else { return unsupported };
|
||||
@@ -1313,7 +1433,15 @@ fn set_from(d: &Descriptor) -> Mapped {
|
||||
(Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => {
|
||||
match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) {
|
||||
Some((a, t)) => match (a.from)(t) {
|
||||
Some(p) => step("layer.setAdjustment", p),
|
||||
// `kind` names the adjustment so Save Actions can write the class back.
|
||||
// Not on an empty set (Photoshop's "reset to default"): params with any key
|
||||
// merge, none reset.
|
||||
Some(mut p) => {
|
||||
if let Some(o) = p.as_object_mut().filter(|o| !o.is_empty()) {
|
||||
o.insert("kind".into(), json!(a.suffix));
|
||||
}
|
||||
step("layer.setAdjustment", p)
|
||||
}
|
||||
None => unsupported,
|
||||
},
|
||||
None => unsupported,
|
||||
@@ -1353,6 +1481,21 @@ fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
}
|
||||
desc("null").with("null", quick_mask_ref())
|
||||
}
|
||||
"tools.setColors" => {
|
||||
// One colour per Photoshop step: a step that sets both (or any other key) doesn't
|
||||
// export rather than lose one.
|
||||
if p.as_object().is_some_and(|o| o.keys().any(|k| k != "foreground" && k != "background")) {
|
||||
return None;
|
||||
}
|
||||
let (key, hex) = match (jstr(p, "foreground"), jstr(p, "background")) {
|
||||
(Some(f), None) => ("FrgC", f),
|
||||
(None, Some(b)) => ("BckC", b),
|
||||
_ => return None,
|
||||
};
|
||||
desc("null")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: code(key) }]))
|
||||
.with("T ", D::Descriptor(color_desc(hex)?))
|
||||
}
|
||||
"select.rect" => {
|
||||
let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?);
|
||||
let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" };
|
||||
@@ -1445,10 +1588,18 @@ fn select_from(d: &Descriptor) -> Mapped {
|
||||
}
|
||||
}
|
||||
(Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true },
|
||||
// A menu item chosen while recording (`Mn`): the View menu's zoom items replay.
|
||||
(Some("Mn"), Target::Ordinal(item)) => match lookup(MENU_ITEMS, &item) {
|
||||
Some(id) => none(id),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
}
|
||||
|
||||
/// `MnIt` menu-item codes ↔ the shell's view commands.
|
||||
const MENU_ITEMS: &[(&str, &str)] = &[("FtOn", "view.fitOnScreen"), ("ActP", "view.actualPixels"), ("ZmIn", "view.zoomIn"), ("ZmOt", "view.zoomOut")];
|
||||
|
||||
fn select_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]);
|
||||
let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]);
|
||||
@@ -1479,7 +1630,10 @@ fn select_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
"layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")),
|
||||
"layer.selectTop" => desc("null").with("null", ord("Frnt")),
|
||||
"layer.selectBottom" => desc("null").with("null", ord("Back")),
|
||||
_ => return None,
|
||||
_ => {
|
||||
let item = lookup_back(MENU_ITEMS, id)?;
|
||||
desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: code("MnIt"), value: code(item) }]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1600,7 +1754,9 @@ fn close_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything
|
||||
/// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`.
|
||||
fn transform_from(d: &Descriptor) -> Mapped {
|
||||
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
|
||||
// No reference (a smart object replaced in place, `replaceLayer`) means the target layer.
|
||||
let target_layer = [ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }];
|
||||
let r: &[ReferenceItem] = get_ref(d, "null").unwrap_or(&target_layer);
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
let mut p = Map::new();
|
||||
if let Some(o) = get_obj(d, "Ofst") {
|
||||
@@ -1861,6 +2017,16 @@ fn visibility_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
|
||||
fn move_from(d: &Descriptor) -> Mapped {
|
||||
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
|
||||
// The selection outline nudged.
|
||||
if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Property(p) if p == "fsel") {
|
||||
return match get_obj(d, "T ") {
|
||||
Some(t) if matches!(t.get("Hrzn"), Some(D::UnitFloat { unit: PX, .. })) => match (get_num(t, "Hrzn"), get_num(t, "Vrtc")) {
|
||||
(Some(dx), Some(dy)) => step("select.transformSelection", json!({"dx": dx.round(), "dy": dy.round()})),
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
}
|
||||
if ref_class(r).as_deref() != Some("Lyr") {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
@@ -1896,6 +2062,8 @@ fn move_from(d: &Descriptor) -> Mapped {
|
||||
Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward",
|
||||
Target::Offset(1) => "layer.arrange.bringForward",
|
||||
Target::Offset(-1) => "layer.arrange.sendBackward",
|
||||
// Index 0 is the bottom of the stack.
|
||||
Target::Index(0) => "layer.arrange.sendToBack",
|
||||
_ => return Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
step(id, Value::Object(p))
|
||||
@@ -2071,7 +2239,9 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if let Some(o) = jnum(p, "opacity") {
|
||||
d = d.with("Opct", pct(o));
|
||||
}
|
||||
d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true)));
|
||||
if let Some(c) = jbool(p, "contiguous") {
|
||||
d = d.with("Cntg", D::Boolean(c));
|
||||
}
|
||||
if let Some(m) = jbool(p, "sampleAllLayers") {
|
||||
d = d.with("Mrgd", D::Boolean(m));
|
||||
}
|
||||
@@ -2082,7 +2252,12 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if contents == "color" {
|
||||
d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?));
|
||||
}
|
||||
d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?));
|
||||
if let Some(o) = jnum(p, "opacity") {
|
||||
d = d.with("Opct", pct(o));
|
||||
}
|
||||
if let Some(m) = jstr(p, "mode") {
|
||||
d = d.with("Md ", enm("BlnM", blend_ps(m)?));
|
||||
}
|
||||
if let Some(t) = jbool(p, "preserveTransparency") {
|
||||
d = d.with("PrsT", D::Boolean(t));
|
||||
}
|
||||
@@ -2112,6 +2287,16 @@ fn bucket_color(v: Option<&Value>) -> Option<Option<Descriptor>> {
|
||||
Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0))))
|
||||
}
|
||||
|
||||
/// Preserve the command's effective integer width instead of rounding its input again.
|
||||
fn stroke_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !pixel_target(p) {
|
||||
return None;
|
||||
}
|
||||
let mut params = if p.is_null() { Map::new() } else { p.as_object()?.clone() };
|
||||
params.insert("width".into(), Value::from(crate::extra_cmds::stroke_width(p)));
|
||||
fields_to(&Value::Object(params), STROKE, desc("null"))
|
||||
}
|
||||
|
||||
const STROKE: &[F] = &[
|
||||
F("Wdth", "width", Kind::Long),
|
||||
F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)),
|
||||
@@ -2496,7 +2681,10 @@ fn crop_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if let Some(a) = jnum(p, "angle") {
|
||||
d = d.with("Angl", ang(a));
|
||||
}
|
||||
Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true))))
|
||||
if let Some(del) = jbool(p, "deleteCroppedPixels") {
|
||||
d = d.with("Dlt ", D::Boolean(del));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
fn stop_from(d: &Descriptor) -> Mapped {
|
||||
@@ -2611,14 +2799,18 @@ fn levels_event_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A direct adjustment event (Image › Adjustments › …).
|
||||
/// A direct adjustment event (Image › Adjustments › …). The command id is `image.adjustments.<name>`
|
||||
/// unless it is given.
|
||||
macro_rules! adjustment_event {
|
||||
($code:literal, $class:literal, $name:literal, $label:literal) => {
|
||||
adjustment_event!($code, $class, $name, $label, concat!("image.adjustments.", $name))
|
||||
};
|
||||
($code:literal, $class:literal, $name:literal, $label:literal, $id:expr) => {
|
||||
Event {
|
||||
code: $code,
|
||||
name: $name,
|
||||
label: $label,
|
||||
ids: &[concat!("image.adjustments.", $name)],
|
||||
ids: &[$id],
|
||||
from: |d| match adjustment_by_class($class) {
|
||||
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
@@ -2670,6 +2862,148 @@ fn gallery_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code)))
|
||||
}
|
||||
|
||||
/// Select and Mask as Photoshop CC 2015.5 and later record it (`smartBrushWorkspace`); the
|
||||
/// older Refine Edge event is [`REFINE_EDGE`], which is also what Select and Mask exports as.
|
||||
const SMART_BRUSH: &[F] = &[
|
||||
F("smartBrushRadius", "radius", Kind::Px),
|
||||
F("smartBrushUseSmartRadius", "smartRadius", Kind::Bool),
|
||||
F("smartBrushSmooth", "smooth", Kind::Long),
|
||||
F("smartBrushFeather", "feather", Kind::Px),
|
||||
F("smartBrushContrast", "contrast", Kind::Pct),
|
||||
F("smartBrushShiftEdge", "shiftEdge", Kind::Pct),
|
||||
F("smartBrushDecontaminate", "decontaminate", Kind::Bool),
|
||||
F("smartBrushDeconAmount", "amount", Kind::Pct),
|
||||
F("refineEdgeOutput", "output", Kind::Enum("refineEdgeOutput", REFINE_OUTPUT)),
|
||||
F("sampleAllLayers", "sampleAllLayers", Kind::Bool),
|
||||
];
|
||||
|
||||
/// Select › Color Range's `Clrs` presets ↔ `select.colorRange`'s `select`.
|
||||
const COLOR_RANGE: &[(&str, &str)] = &[
|
||||
("Rds", "reds"),
|
||||
("Ylws", "yellows"),
|
||||
("Grns", "greens"),
|
||||
("Cyns", "cyans"),
|
||||
("Bls", "blues"),
|
||||
("Mgnt", "magentas"),
|
||||
("Hghl", "highlights"),
|
||||
("Mdtn", "midtones"),
|
||||
("Shdw", "shadows"),
|
||||
("OtOf", "outOfGamut"),
|
||||
("skinTone", "skinTones"),
|
||||
];
|
||||
|
||||
/// The Color Range keys the converter reads (or that don't change a preset's result:
|
||||
/// `colorModel` is the model of sampled colours).
|
||||
const COLOR_RANGE_KEYS: &[&str] = &[
|
||||
"Clrs",
|
||||
"Fzns",
|
||||
"Invr",
|
||||
"colorModel",
|
||||
"shadowsFuzziness",
|
||||
"shadowsUpperLimit",
|
||||
"midtonesFuzziness",
|
||||
"midtonesLowerLimit",
|
||||
"midtonesUpperLimit",
|
||||
"highlightsFuzziness",
|
||||
"highlightsLowerLimit",
|
||||
];
|
||||
|
||||
/// Color Range (`ClrR`) with a preset. Sampled Colors records Lab minimum and maximum colours,
|
||||
/// which `select.colorRange` (eyedropper points on the image) can't reproduce, and an unknown
|
||||
/// key (Detect Faces, Localized Color Clusters…) could change the result: both stay
|
||||
/// unsupported.
|
||||
fn color_range_from(d: &Descriptor) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
if d.items.iter().any(|(k, _)| !COLOR_RANGE_KEYS.contains(&trimmed(k).as_str())) {
|
||||
return unsupported;
|
||||
}
|
||||
let Some(preset) = get_enum(d, "Clrs").and_then(|c| lookup(COLOR_RANGE, &c)) else { return unsupported };
|
||||
let mut p = Map::new();
|
||||
p.insert("select".into(), preset.into());
|
||||
let key = |suffix: &str| get_num(d, &format!("{preset}{suffix}"));
|
||||
match preset {
|
||||
"skinTones" => {
|
||||
if let Some(f) = get_num(d, "Fzns") {
|
||||
p.insert("fuzziness".into(), f.into());
|
||||
}
|
||||
}
|
||||
"shadows" | "midtones" | "highlights" => {
|
||||
if let Some(f) = key("Fuzziness") {
|
||||
p.insert("fuzziness".into(), f.into());
|
||||
}
|
||||
let range = match (preset, key("LowerLimit"), key("UpperLimit")) {
|
||||
("shadows", _, hi) => hi.map(Value::from),
|
||||
("highlights", lo, _) => lo.map(Value::from),
|
||||
(_, Some(lo), Some(hi)) => Some(json!([lo, hi])),
|
||||
(_, None, None) => None,
|
||||
_ => return unsupported,
|
||||
};
|
||||
if let Some(r) = range {
|
||||
p.insert("tonalRange".into(), r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if get_bool(d, "Invr") == Some(true) {
|
||||
p.insert("invert".into(), true.into());
|
||||
}
|
||||
step("select.colorRange", Value::Object(p))
|
||||
}
|
||||
|
||||
fn color_range_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let preset = jstr(p, "select")?;
|
||||
if p.as_object()?.keys().any(|k| !matches!(k.as_str(), "select" | "fuzziness" | "tonalRange" | "invert")) {
|
||||
return None;
|
||||
}
|
||||
let mut d = desc("null").with("Clrs", enm("Clrs", lookup_back(COLOR_RANGE, preset)?));
|
||||
let tonal = matches!(preset, "shadows" | "midtones" | "highlights");
|
||||
if let Some(f) = jnum(p, "fuzziness") {
|
||||
match preset {
|
||||
"skinTones" => d = d.with("Fzns", long(f)),
|
||||
_ if tonal => d = d.with(&format!("{preset}Fuzziness"), long(f)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(r) = p.get("tonalRange") {
|
||||
match preset {
|
||||
"shadows" => d = d.with("shadowsUpperLimit", long(r.as_f64()?)),
|
||||
"highlights" => d = d.with("highlightsLowerLimit", long(r.as_f64()?)),
|
||||
"midtones" => {
|
||||
let a = r.as_array()?;
|
||||
d = d.with("midtonesLowerLimit", long(a.first()?.as_f64()?)).with("midtonesUpperLimit", long(a.get(1)?.as_f64()?));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if jbool(p, "invert") == Some(true) {
|
||||
d = d.with("Invr", D::Boolean(true));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Create / Release Clipping Mask (`groupEvent` / `ungroup`) on the target or a named layer.
|
||||
fn clipping_from(d: &Descriptor, id: &str) -> Mapped {
|
||||
match get_ref(d, "null") {
|
||||
None => none(id),
|
||||
Some(r) if ref_class(r).as_deref() == Some("Lyr") => match layer_target(r) {
|
||||
Some(p) => step(id, Value::Object(p)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
Some(_) => Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn clipping_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
Some(desc("null").with("null", layer_ref(p)))
|
||||
}
|
||||
|
||||
/// A clipping mask event: `clipping_from` / `clipping_to` with one command id.
|
||||
macro_rules! clipping_event {
|
||||
($code:literal, $name:literal, $label:literal, $id:literal) => {
|
||||
Event { code: $code, name: $name, label: $label, ids: &[$id], from: |d| clipping_from(d, $id), to: clipping_to }
|
||||
};
|
||||
}
|
||||
|
||||
/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label.
|
||||
const EVENTS: &[Event] = &[
|
||||
// Filters.
|
||||
@@ -2696,6 +3030,14 @@ const EVENTS: &[Event] = &[
|
||||
ev!("Mdn ", "median", "Median", "filter.noise.median", &[F("Rds ", "radius", Kind::Px)]),
|
||||
ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]),
|
||||
ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]),
|
||||
// Photoshop records Emboss's angle, height and amount as plain integers (#7946).
|
||||
ev!(
|
||||
"Embs",
|
||||
"emboss",
|
||||
"Emboss",
|
||||
"filter.stylize.emboss",
|
||||
&[F("Angl", "angle", Kind::Long), F("Hght", "height", Kind::Long), F("Amnt", "amount", Kind::Long)]
|
||||
),
|
||||
ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]),
|
||||
ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]),
|
||||
ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]),
|
||||
@@ -2724,8 +3066,8 @@ const EVENTS: &[Event] = &[
|
||||
ev!("ShrM", "sharpenMore", "Sharpen More", "filter.sharpen.sharpenMore", &[]),
|
||||
ev!("ShrE", "sharpenEdges", "Sharpen Edges", "filter.sharpen.sharpenEdges", &[]),
|
||||
ev!("HghP", "highPass", "High Pass", "filter.other.highPass", &[F("Rds ", "radius", Kind::Px)]),
|
||||
ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)]),
|
||||
ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)]),
|
||||
ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)], unless preserves_roundness),
|
||||
ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)], unless preserves_roundness),
|
||||
ev!(
|
||||
"Ofst",
|
||||
"offset",
|
||||
@@ -2733,7 +3075,7 @@ const EVENTS: &[Event] = &[
|
||||
"filter.other.offset",
|
||||
&[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
|
||||
),
|
||||
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
|
||||
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
|
||||
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]),
|
||||
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
|
||||
// Adjustments (Image › Adjustments).
|
||||
@@ -2755,17 +3097,7 @@ const EVENTS: &[Event] = &[
|
||||
},
|
||||
adjustment_event!("Crvs", "Crvs", "curves", "Curves"),
|
||||
adjustment_event!("HStr", "HStr", "hueSaturation", "Hue/Saturation"),
|
||||
Event {
|
||||
code: "BrgC",
|
||||
name: "brightnessEvent",
|
||||
label: "Brightness/Contrast",
|
||||
ids: &["image.adjustments.brightnessContrast"],
|
||||
from: |d| match adjustment_by_class("BrgC") {
|
||||
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: |_, p| adjustment_by_class("BrgC").and_then(|a| (a.to)(p)),
|
||||
},
|
||||
adjustment_event!("BrgC", "BrgC", "brightnessEvent", "Brightness/Contrast", "image.adjustments.brightnessContrast"),
|
||||
adjustment_event!("Thrs", "Thrs", "threshold", "Threshold"),
|
||||
adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"),
|
||||
adjustment_event!("Invr", "Invr", "invert", "Invert"),
|
||||
@@ -2773,29 +3105,9 @@ const EVENTS: &[Event] = &[
|
||||
adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"),
|
||||
adjustment_event!("Exps", "Exps", "exposure", "Exposure"),
|
||||
adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"),
|
||||
Event {
|
||||
code: "",
|
||||
name: "blackAndWhite",
|
||||
label: "Black & White",
|
||||
ids: &["image.adjustments.blackWhite"],
|
||||
from: |d| match adjustment_by_class("BlnW") {
|
||||
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)),
|
||||
},
|
||||
adjustment_event!("", "BlnW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"),
|
||||
adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"),
|
||||
Event {
|
||||
code: "GdMp",
|
||||
name: "gradientMapEvent",
|
||||
label: "Gradient Map",
|
||||
ids: &["image.adjustments.gradientMap"],
|
||||
from: |d| match adjustment_by_class("GdMp") {
|
||||
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)),
|
||||
},
|
||||
adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"),
|
||||
adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"),
|
||||
ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]),
|
||||
ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]),
|
||||
@@ -2895,6 +3207,10 @@ const EVENTS: &[Event] = &[
|
||||
"layer.newAdjustmentLayer.channelMixer",
|
||||
"layer.newAdjustmentLayer.selectiveColor",
|
||||
"layer.newAdjustmentLayer.gradientMap",
|
||||
"layer.layerMask.revealAll",
|
||||
"layer.layerMask.hideAll",
|
||||
"layer.layerMask.revealSelection",
|
||||
"layer.layerMask.hideSelection",
|
||||
"file.new",
|
||||
"file.newFromClipboard",
|
||||
],
|
||||
@@ -2913,6 +3229,7 @@ const EVENTS: &[Event] = &[
|
||||
"select.rect",
|
||||
"select.lasso",
|
||||
"select.editInQuickMaskMode",
|
||||
"tools.setColors",
|
||||
"layer.setProps",
|
||||
"layer.renameLayer",
|
||||
"layer.setLabelColor",
|
||||
@@ -2938,6 +3255,10 @@ const EVENTS: &[Event] = &[
|
||||
"channel.target",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"view.fitOnScreen",
|
||||
"view.actualPixels",
|
||||
"view.zoomIn",
|
||||
"view.zoomOut",
|
||||
],
|
||||
from: select_from,
|
||||
to: select_to,
|
||||
@@ -2976,11 +3297,35 @@ const EVENTS: &[Event] = &[
|
||||
to: merge_visible_to,
|
||||
},
|
||||
ev!("Mrg2", "mergeLayersNew", "Merge Layers", "layer.mergeLayers", &[]),
|
||||
clipping_event!("Ungr", "ungroup", "Release Clipping Mask", "layer.releaseClippingMask"),
|
||||
ev!("CpTL", "copyToLayer", "Layer Via Copy", "layer.new.layerViaCopy", &[]),
|
||||
ev!("CtTL", "cutToLayer", "Layer Via Cut", "layer.new.layerViaCut", &[]),
|
||||
ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]),
|
||||
ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]),
|
||||
ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]),
|
||||
clipping_event!("GrpL", "groupEvent", "Create Clipping Mask", "layer.createClippingMask"),
|
||||
ev!("", "ungroupLayersEvent", "Ungroup Layers", "layer.ungroupLayers", &[]),
|
||||
ev!("", "copyEffects", "Copy Layer Style", "layer.layerStyle.copyLayerStyle", &[]),
|
||||
ev!("", "pasteEffects", "Paste Layer Style", "layer.layerStyle.pasteLayerStyle", &[]),
|
||||
Event {
|
||||
code: "",
|
||||
name: "collapseAllGroupsEvent",
|
||||
label: "Collapse All Groups",
|
||||
ids: &["layer.setExpanded"],
|
||||
from: |_| step("layer.setExpanded", json!({"all": true, "expanded": false})),
|
||||
to: |_, p| (jbool(p, "all") == Some(true) && jbool(p, "expanded") == Some(false)).then(|| desc("null")),
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "applyComp",
|
||||
label: "Apply Layer Comp",
|
||||
ids: &["layerComp.apply"],
|
||||
from: |d| match get_ref(d, "null").map(target) {
|
||||
Some(Target::Name(n)) => step("layerComp.apply", json!({"comp": n})),
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: |_, p| Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))),
|
||||
},
|
||||
ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]),
|
||||
Event {
|
||||
code: "Algn",
|
||||
@@ -3006,10 +3351,35 @@ const EVENTS: &[Event] = &[
|
||||
ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]),
|
||||
ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]),
|
||||
ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]),
|
||||
ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE),
|
||||
// `refineSelectionEdge` has no Sample All Layers, so a step with it exports as
|
||||
// `smartBrushWorkspace` instead of dropping it.
|
||||
Event {
|
||||
code: "",
|
||||
name: "refineSelectionEdge",
|
||||
label: "Select and Mask",
|
||||
ids: &["select.refineEdge"],
|
||||
from: |d| match fields_from(d, REFINE_EDGE) {
|
||||
Some(m) => step("select.refineEdge", Value::Object(m)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: |_, p| if p.get("sampleAllLayers").is_some() { None } else { fields_to(p, REFINE_EDGE, desc("null")) },
|
||||
},
|
||||
ev!("", "smartBrushWorkspace", "Select and Mask", "select.refineEdge", SMART_BRUSH),
|
||||
ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]),
|
||||
Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to },
|
||||
// Edit.
|
||||
Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to },
|
||||
ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE),
|
||||
Event {
|
||||
code: "Strk",
|
||||
name: "stroke",
|
||||
label: "Stroke",
|
||||
ids: &["edit.stroke"],
|
||||
from: |d| match fields_from(d, STROKE) {
|
||||
Some(m) => step("edit.stroke", Value::Object(m)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
to: stroke_to,
|
||||
},
|
||||
ev!("copy", "copyEvent", "Copy", "edit.copy", &[]),
|
||||
ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]),
|
||||
ev!("cut ", "cut", "Cut", "edit.cut", &[]),
|
||||
@@ -3098,7 +3468,7 @@ pub struct Imported {
|
||||
|
||||
fn photoshop_step(item: &AtnItem) -> (String, Value) {
|
||||
let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes()));
|
||||
(PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor}))
|
||||
(PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.label(), "descriptor": descriptor}))
|
||||
}
|
||||
|
||||
/// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings
|
||||
@@ -3133,7 +3503,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>)
|
||||
let (new_steps, enabled) = match mapped {
|
||||
Mapped::Steps(s) => (s, item.enabled),
|
||||
Mapped::Unsupported { harmless } => {
|
||||
unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() });
|
||||
unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() });
|
||||
(vec![photoshop_step(item)], item.enabled && !harmless)
|
||||
}
|
||||
};
|
||||
@@ -3184,21 +3554,21 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
|
||||
let event = jstr(params, "event")?;
|
||||
let event = match event.as_bytes() {
|
||||
[a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]),
|
||||
_ => AtnEvent::Name(event.to_string()),
|
||||
_ => AtnEvent::name(event),
|
||||
};
|
||||
let descriptor = match jstr(params, "descriptor") {
|
||||
Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?),
|
||||
None => None,
|
||||
};
|
||||
return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() });
|
||||
return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").as_bytes().to_vec(), descriptor, ..Default::default() });
|
||||
}
|
||||
let mut params = params.clone();
|
||||
if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() {
|
||||
params["set"] = json!(set);
|
||||
}
|
||||
let (e, descriptor) = event_and_descriptor(id, ¶ms)?;
|
||||
let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
|
||||
Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() })
|
||||
let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
|
||||
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() })
|
||||
}
|
||||
|
||||
/// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options
|
||||
|
||||
@@ -6,9 +6,9 @@ use serde_json::json;
|
||||
fn item(event: &str, label: &str, d: Option<Descriptor>) -> AtnItem {
|
||||
let event = match event.as_bytes() {
|
||||
[a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]),
|
||||
_ => AtnEvent::Name(event.into()),
|
||||
_ => AtnEvent::name(event),
|
||||
};
|
||||
AtnItem { event, dictionary: label.into(), descriptor: d, ..Default::default() }
|
||||
AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor: d, ..Default::default() }
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
@@ -199,6 +199,50 @@ fn save_round_trips_through_photoshop_events() {
|
||||
assert_eq!(r["set"], "Ours");
|
||||
}
|
||||
|
||||
/// `image.adjustments.selectiveColor` takes a range as an array (`"reds": [c, m, y, k]`) or as
|
||||
/// the scalar form that names one range (`"colors": "reds", "cyan": 35`). Only the arrays were
|
||||
/// written, so a step recorded the other way exported as an adjustment that changes nothing, with
|
||||
/// `skipped` empty (#7672).
|
||||
#[test]
|
||||
fn a_selective_color_step_in_the_scalar_form_keeps_its_values() {
|
||||
let back = |params: Value| -> Value {
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), params)]));
|
||||
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]));
|
||||
let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
imported.actions[0].steps[0].1.clone()
|
||||
};
|
||||
|
||||
// The numbers are read back as numbers; a percentage may arrive as 35 or 35.0.
|
||||
let range =
|
||||
|step: &Value, name: &str| -> Vec<f64> { step[name].as_array().unwrap_or(&Vec::new()).iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect() };
|
||||
|
||||
// The reported step: one range, one channel.
|
||||
let scalar = back(json!({"method": "absolute", "colors": "reds", "cyan": 35}));
|
||||
assert_eq!(scalar["method"], "absolute");
|
||||
assert_eq!(range(&scalar, "reds"), [35.0, 0.0, 0.0, 0.0], "{scalar}");
|
||||
|
||||
// The scalar form over an array keeps both, as playing the step would.
|
||||
let both = back(json!({"reds": [10, 20, 30, 40], "colors": "reds", "cyan": 35}));
|
||||
assert_eq!(range(&both, "reds"), [35.0, 20.0, 30.0, 40.0], "{both}");
|
||||
|
||||
// The array form is unchanged.
|
||||
let array = back(json!({"method": "relative", "yellows": [0, 0, -15, 5]}));
|
||||
assert_eq!(range(&array, "yellows"), [0.0, 0.0, -15.0, 5.0], "{array}");
|
||||
|
||||
// A channel the command would refuse is not quietly dropped: the step is refused too, since
|
||||
// writing it would export an adjustment that is not the one recorded. `null` is absent, as
|
||||
// the command reads it.
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), json!({"colors": "reds", "cyan": "35"}))]));
|
||||
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
|
||||
assert!(e.contains("image.adjustments.selectiveColor"), "{e}");
|
||||
|
||||
let nulled = back(json!({"colors": "reds", "cyan": 35, "magenta": null}));
|
||||
assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mapped_command_exports_and_imports_back() {
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
@@ -206,6 +250,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})),
|
||||
("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})),
|
||||
("filter.noise.median", json!({"radius": 2})),
|
||||
("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})),
|
||||
("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})),
|
||||
("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})),
|
||||
("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})),
|
||||
@@ -236,7 +281,20 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": true, "tintColor": "#e4d5b0"}),
|
||||
),
|
||||
("image.adjustments.channelMixer", json!({"red": [100, 0, 0, 0], "green": [0, 90, 10, 0], "blue": [0, 0, 100, 5], "monochrome": false})),
|
||||
("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "neutrals": [0, 0, 0, 3]})),
|
||||
("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "yellows": [0, 5, 0, 0], "neutrals": [0, 0, 0, 3]})),
|
||||
("select.subject", json!({"sampleAllLayers": false})),
|
||||
("select.colorRange", json!({"select": "midtones", "fuzziness": 30, "tonalRange": [90, 160], "invert": true})),
|
||||
("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})),
|
||||
("select.colorRange", json!({"select": "highlights", "tonalRange": 200})),
|
||||
("select.colorRange", json!({"select": "reds"})),
|
||||
("layer.createClippingMask", json!({})),
|
||||
("layer.releaseClippingMask", json!({"layer": "Shadow"})),
|
||||
("layer.layerMask.revealAll", json!({})),
|
||||
("layer.layerMask.hideAll", json!({})),
|
||||
("layer.layerMask.revealSelection", json!({})),
|
||||
("layer.layerMask.hideSelection", json!({})),
|
||||
("tools.setColors", json!({"foreground": "#336699"})),
|
||||
("tools.setColors", json!({"background": "#ffffff"})),
|
||||
("image.adjustments.desaturate", json!({})),
|
||||
("image.autoTone", json!({})),
|
||||
("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})),
|
||||
@@ -337,6 +395,16 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("file.openAs", json!({"path": "/tmp/a.psd"})),
|
||||
("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})),
|
||||
("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})),
|
||||
("tools.setColors", json!({"foreground": "#102030"})),
|
||||
("tools.setColors", json!({"background": "#ffffff"})),
|
||||
("view.fitOnScreen", json!({})),
|
||||
("view.zoomIn", json!({})),
|
||||
("layer.createClippingMask", json!({})),
|
||||
("layer.ungroupLayers", json!({})),
|
||||
("layer.layerStyle.copyLayerStyle", json!({})),
|
||||
("layer.layerStyle.pasteLayerStyle", json!({})),
|
||||
("layer.setExpanded", json!({"all": true, "expanded": false})),
|
||||
("layerComp.apply", json!({"comp": "Original"})),
|
||||
("actions.play", json!({"action": "Other", "set": "S"})),
|
||||
(STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})),
|
||||
// The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine).
|
||||
@@ -437,6 +505,8 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
"select.modify.smooth",
|
||||
"select.reselect",
|
||||
"select.similar",
|
||||
"view.actualPixels",
|
||||
"view.zoomOut",
|
||||
"tools.swapColors",
|
||||
"edit.cut",
|
||||
"edit.copy",
|
||||
@@ -465,7 +535,10 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
// Every command the table names exists.
|
||||
// (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.)
|
||||
for id in EVENTS.iter().flat_map(|e| e.ids.iter()) {
|
||||
assert!(crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id), "the .atn table names an unknown command `{id}`");
|
||||
assert!(
|
||||
crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id),
|
||||
"the .atn table names an unknown command `{id}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +591,45 @@ fn bucket_keeps_an_explicit_color_through_atn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `image.adjustments.photoFilter` exported to .atn and imported back → its params, or None.
|
||||
fn photo_filter_round_trip(params: Value) -> Option<Value> {
|
||||
let item = export_item("image.adjustments.photoFilter", ¶ms, "S")?;
|
||||
let e = event_for(&item.event)?;
|
||||
match (e.from)(item.descriptor.as_ref()?) {
|
||||
Mapped::Steps(steps) => steps.first().map(|(id, p)| {
|
||||
assert_eq!(id, "image.adjustments.photoFilter");
|
||||
p.clone()
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_filter_preset_only_step_keeps_its_resolved_color() {
|
||||
// #7235: the Photoshop descriptor keeps only a colour (`phfl`/`PhtF` are colour-only; the io
|
||||
// notes reach the same conclusion from the CS2 oracles). A step recorded with only `filter`
|
||||
// previously exported with no colour at all, so re-imported playback fell back to the default
|
||||
// warming filter; it must export the resolved colour of the chosen preset instead.
|
||||
let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "density": 60, "preserveLuminosity": true})).unwrap();
|
||||
normalise(&mut back);
|
||||
assert_eq!(back, json!({"color": "#006dff", "density": 60, "preserveLuminosity": true}));
|
||||
// An omitted density stays omitted: running the imported step applies the same default.
|
||||
let mut back = photo_filter_round_trip(json!({"filter": "red"})).unwrap();
|
||||
normalise(&mut back);
|
||||
assert_eq!(back, json!({"color": "#ea1a1a"}));
|
||||
// With an explicit colour too, the colour wins, as when running the command.
|
||||
let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "color": "#123456", "density": 25})).unwrap();
|
||||
normalise(&mut back);
|
||||
assert_eq!(back, json!({"color": "#123456", "density": 25}));
|
||||
let mut back = photo_filter_round_trip(json!({"color": "#123456", "density": 25})).unwrap();
|
||||
normalise(&mut back);
|
||||
assert_eq!(back, json!({"color": "#123456", "density": 25}));
|
||||
// An unknown preset id keeps the old export: no colour resolves, playback applies its default.
|
||||
let mut back = photo_filter_round_trip(json!({"filter": "nope", "density": 25})).unwrap();
|
||||
normalise(&mut back);
|
||||
assert_eq!(back, json!({"density": 25}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
||||
// Image size in two different percentages, a Paint Bucket click in percent of the canvas, a
|
||||
@@ -565,6 +677,127 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
||||
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
|
||||
}
|
||||
|
||||
/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening,
|
||||
/// highlight cleanup, high-pass sharpening), in the stringID form Photoshop wrote them.
|
||||
#[test]
|
||||
fn retouching_set_events_map_to_their_commands() {
|
||||
let smart_brush = desc("null")
|
||||
.with("smartBrushRadius", px(3.0))
|
||||
.with("smartBrushSmooth", long(2.0))
|
||||
.with("smartBrushFeather", px(0.5))
|
||||
.with("smartBrushContrast", pct(10.0))
|
||||
.with("smartBrushShiftEdge", pct(-20.0))
|
||||
.with("sampleAllLayers", D::Boolean(false))
|
||||
.with("smartBrushUseSmartRadius", D::Boolean(true))
|
||||
.with("smartBrushDecontaminate", D::Boolean(false))
|
||||
.with("smartBrushDeconAmount", pct(100.0))
|
||||
.with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask"));
|
||||
let selective = desc("null").with("Mthd", enm("CrcM", "Rltv")).with(
|
||||
"ClrC",
|
||||
D::List(vec![
|
||||
D::Descriptor(
|
||||
desc("ClrC").with("Clrs", enm("Clrs", "Ylws")).with("Cyn ", pct(-30.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(-40.0)).with("Blck", pct(0.0)),
|
||||
),
|
||||
D::Descriptor(
|
||||
desc("ClrC").with("Clrs", enm("Clrs", "Whts")).with("Cyn ", pct(0.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(0.0)).with("Blck", pct(-10.0)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let lab = |l: f64| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(l)).with("A ", D::Double(0.0)).with("B ", D::Double(0.0)));
|
||||
let sampled = desc("null").with("Fzns", long(40.0)).with("Mnm ", lab(80.0)).with("Mxm ", lab(95.0)).with("colorModel", long(0.0));
|
||||
let mask = desc("null")
|
||||
.with("Nw ", D::Class(class("Chnl")))
|
||||
.with("At ", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]))
|
||||
.with("Usng", enm("UsrM", "RvlS"));
|
||||
let fg = desc("null")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }]))
|
||||
.with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0))));
|
||||
let lab_fg = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("BckC") }])).with("T ", lab(50.0));
|
||||
let items = vec![
|
||||
item("autoCutout", "Select Subject", Some(desc("null").with("sampleAllLayers", D::Boolean(false)))),
|
||||
item("smartBrushWorkspace", "Select and Mask", Some(smart_brush)),
|
||||
item("selectiveColor", "Selective Color", Some(selective)),
|
||||
item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "skinTone")).with("Fzns", long(40.0)))),
|
||||
item("colorRange", "Color Range", Some(sampled)),
|
||||
item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "Rds ")).with("UsFc", D::Boolean(true)))),
|
||||
item("make", "Make", Some(mask)),
|
||||
item("set", "Set", Some(fg)),
|
||||
item("set", "Set", Some(lab_fg)),
|
||||
item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))),
|
||||
item("GrpL", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))),
|
||||
item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", name_ref("Lyr ", "Shadow")))),
|
||||
item("someFutureEvent", "Something New", Some(desc("null").with("Amnt", long(5.0)))),
|
||||
];
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Retouch"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let steps = &imp.actions[0].steps;
|
||||
let expected = [
|
||||
("select.subject", json!({"sampleAllLayers": false})),
|
||||
(
|
||||
"select.refineEdge",
|
||||
json!({"radius": 3, "smartRadius": true, "smooth": 2, "feather": 0.5, "contrast": 10, "shiftEdge": -20, "decontaminate": false, "amount": 100, "output": "layerMask", "sampleAllLayers": false}),
|
||||
),
|
||||
("image.adjustments.selectiveColor", json!({"method": "relative", "yellows": [-30, 0, -40, 0], "whites": [0, 0, 0, -10]})),
|
||||
("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})),
|
||||
(PHOTOSHOP_STEP, Value::Null),
|
||||
(PHOTOSHOP_STEP, Value::Null),
|
||||
("layer.layerMask.revealSelection", json!({})),
|
||||
("tools.setColors", json!({"foreground": "#0000ff"})),
|
||||
(PHOTOSHOP_STEP, Value::Null),
|
||||
("layer.createClippingMask", json!({})),
|
||||
("layer.createClippingMask", json!({})),
|
||||
("layer.createClippingMask", json!({"layer": "Shadow"})),
|
||||
(PHOTOSHOP_STEP, Value::Null),
|
||||
];
|
||||
assert_eq!(steps.len(), expected.len(), "{steps:?}");
|
||||
for ((id, params), (want_id, want)) in steps.iter().zip(&expected) {
|
||||
assert_eq!(id, want_id, "{steps:?}");
|
||||
if *want_id != PHOTOSHOP_STEP {
|
||||
assert_eq!(params, want, "{id}");
|
||||
}
|
||||
}
|
||||
// Sampled Colors (Lab range), an unknown Color Range key, a Lab colour and an unknown
|
||||
// event stay unsupported rather than play with other values.
|
||||
let unsupported: Vec<(&str, usize)> = imp.unsupported.iter().map(|u| (u.event.as_str(), u.step)).collect();
|
||||
assert_eq!(unsupported, [("colorRange", 4), ("colorRange", 5), ("set", 8), ("someFutureEvent", 12)]);
|
||||
// A legacy PhotoCraft export's `Yllw` yellows still read.
|
||||
let legacy = desc("null").with("ClrC", D::List(vec![D::Descriptor(desc("ClrC").with("Clrs", enm("Clrs", "Yllw")).with("Cyn ", pct(5.0)))]));
|
||||
assert_eq!(selective_from(&legacy), Some(json!({"yellows": [5.0, 0.0, 0.0, 0.0]})));
|
||||
// The mask of a named layer is not the target layer's mask: it stays unsupported.
|
||||
let other_layer = desc("null")
|
||||
.with("Nw ", D::Class(class("Chnl")))
|
||||
.with(
|
||||
"At ",
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") },
|
||||
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Other") },
|
||||
]),
|
||||
)
|
||||
.with("Usng", enm("UsrM", "RvlA"));
|
||||
assert!(matches!(make_from(&other_layer), Mapped::Unsupported { harmless: false }));
|
||||
}
|
||||
|
||||
/// Select and Mask exports as the older `refineSelectionEdge` event, which has no Sample All
|
||||
/// Layers; a step that sets it exports as `smartBrushWorkspace` so the value survives.
|
||||
#[test]
|
||||
fn select_and_mask_keeps_sample_all_layers_on_export() {
|
||||
let (event, d) = event_and_descriptor("select.refineEdge", &json!({"radius": 3, "output": "layerMask"})).unwrap();
|
||||
assert_eq!(event.name, "refineSelectionEdge");
|
||||
assert!(d.get("refineEdgeBorderRadius").is_some());
|
||||
for sample in [true, false] {
|
||||
let params = json!({"radius": 3, "output": "layerMask", "sampleAllLayers": sample});
|
||||
let (event, d) = event_and_descriptor("select.refineEdge", ¶ms).unwrap();
|
||||
assert_eq!(event.name, "smartBrushWorkspace");
|
||||
let Mapped::Steps(steps) = (event.from)(&d) else { panic!("{d:?}") };
|
||||
assert_eq!(steps[0].1["sampleAllLayers"], sample);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_sides_in_other_units_are_refused_not_played_as_pixels() {
|
||||
let rect = |unit: [u8; 4]| {
|
||||
@@ -885,6 +1118,50 @@ fn remaining_events_import_in_their_photoshop_shapes() {
|
||||
let rest_steps = &rest.actions[0].steps;
|
||||
let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]);
|
||||
// A transform without a reference (a replaced smart object) is the target layer's; the
|
||||
// selection outline nudged; a layer moved to index 0 (the bottom); a View menu item; the
|
||||
// foreground colour.
|
||||
let replaced = desc("null")
|
||||
.with("FTcs", enm("QCSt", "Qcsa"))
|
||||
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(4.0)).with("Vrtc", px(6.0))))
|
||||
.with("Wdth", pct(50.0))
|
||||
.with("Hght", pct(50.0))
|
||||
.with("replaceLayer", D::Descriptor(desc("null")));
|
||||
let nudged = desc("null")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]))
|
||||
.with("T ", D::Descriptor(desc("Ofst").with("Hrzn", px(3.0)).with("Vrtc", px(-2.0))));
|
||||
let to_bottom = desc("null")
|
||||
.with("null", target_ref("Lyr "))
|
||||
.with("T ", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 0 }]))
|
||||
.with("Adjs", D::Boolean(false));
|
||||
let fit =
|
||||
desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: Id::new("MnIt"), value: Id::new("FtOn") }]));
|
||||
let fg = desc("null")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }]))
|
||||
.with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0))));
|
||||
let items = vec![
|
||||
item("transform", "Transform", Some(replaced)),
|
||||
item("move", "Move", Some(nudged)),
|
||||
item("move", "Move", Some(to_bottom)),
|
||||
item("select", "Select", Some(fit)),
|
||||
item("set", "Set", Some(fg)),
|
||||
];
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("More2"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
};
|
||||
let more = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let more_steps = &more.actions[0].steps;
|
||||
assert_eq!(
|
||||
more_steps.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||
["edit.transform", "select.transformSelection", "layer.arrange.sendToBack", "view.fitOnScreen", "tools.setColors"]
|
||||
);
|
||||
assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50}));
|
||||
assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2}));
|
||||
assert_eq!(more_steps[4].1, json!({"foreground": "#000000"}));
|
||||
assert!(more.unsupported.is_empty());
|
||||
assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"}));
|
||||
assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"}));
|
||||
assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false}));
|
||||
@@ -1281,6 +1558,28 @@ fn save_while_recording_includes_pending_steps() {
|
||||
assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication");
|
||||
}
|
||||
|
||||
/// #7233: the Minimum / Maximum descriptor carries the radius only, so a step with Preserve:
|
||||
/// Roundness is refused by strict export (and left out, reported, otherwise) instead of replaying
|
||||
/// as the default Squareness. Squareness, explicit or by default, still round-trips.
|
||||
#[test]
|
||||
fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() {
|
||||
for id in ["filter.other.minimum", "filter.other.maximum"] {
|
||||
let round = Action { name: "Round".into(), steps: vec![(id.into(), json!({"radius": 2, "preserve": "roundness"}))], ..Default::default() };
|
||||
assert!(export("Audit", &[&round], &[], false).is_err(), "{id}: strict export refuses it");
|
||||
let (_, skipped) = export("Audit", &[&round], &[], true).unwrap();
|
||||
assert_eq!(skipped, vec![Skipped { action: "Round".into(), step: 0, id: id.into() }], "{id}");
|
||||
for p in [json!({"radius": 2}), json!({"radius": 2, "preserve": "squareness"})] {
|
||||
let square = Action { name: "Square".into(), steps: vec![(id.into(), p.clone())], ..Default::default() };
|
||||
let (bytes, skipped) = export("Audit", &[&square], &[], false).unwrap();
|
||||
assert!(skipped.is_empty(), "{id} {p}");
|
||||
let imported = import(&bytes).unwrap();
|
||||
let (got, params) = &imported.actions[0].steps[0];
|
||||
assert_eq!((got.as_str(), params["radius"].as_f64()), (id, Some(2.0)), "{id} {p}");
|
||||
assert!(params.get("preserve").is_none_or(|v| v == "squareness"), "{id} {p}: {params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's Close has no document target: a Close of a specific document must not be
|
||||
/// exported as a plain Close, which would close the active document on playback.
|
||||
#[test]
|
||||
@@ -1336,6 +1635,28 @@ fn arbitrary_rotation_keeps_interpolation_through_atn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter › Stylize › Emboss saves as Photoshop's `Embs` event with integer `Angl`, `Hght` and
|
||||
/// `Amnt`, and loads back as the same step (#7946: it was skipped, so a portable action lost it).
|
||||
#[test]
|
||||
fn emboss_saves_as_photoshops_event_and_loads_back() {
|
||||
let mut s = Session::new();
|
||||
let params = json!({"angle": 130, "height": 1, "amount": 70});
|
||||
s.actions.list.push(Action::new("Pore", vec![("filter.stylize.emboss".into(), params.clone())]));
|
||||
let r = s.execute("actions.save", json!({"action": "Pore"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]));
|
||||
let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap();
|
||||
let file = atn::parse(&saved).unwrap();
|
||||
let step = &file.actions[0].items[0];
|
||||
assert_eq!(step.event.id(), "Embs");
|
||||
let d = step.descriptor.as_ref().unwrap();
|
||||
for (key, want) in [("Angl", 130), ("Hght", 1), ("Amnt", 70)] {
|
||||
assert_eq!(d.get(key), Some(&D::Integer(want)), "{key}");
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.execute("actions.load", json!({"data": b64(&saved)})).unwrap();
|
||||
assert_eq!(t.actions.list[0].steps, vec![("filter.stylize.emboss".to_string(), params)]);
|
||||
}
|
||||
|
||||
/// A filter recorded against a layer mask must not export as a plain filter that edits the
|
||||
/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`.
|
||||
#[test]
|
||||
@@ -1401,3 +1722,51 @@ fn function_keys_keep_shift_and_command() {
|
||||
}
|
||||
assert_eq!(super::function_key_name(7, false, false), "F7");
|
||||
}
|
||||
|
||||
/// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays
|
||||
/// the missing key differently (checked against Photoshop): Canvas Size anchors top left,
|
||||
/// Apply Image blends Normal, Color Balance turns preserve luminosity off, and a Paint Bucket
|
||||
/// step without a tolerance doesn't play at all.
|
||||
#[test]
|
||||
fn defaults_photoshop_plays_differently_are_written() {
|
||||
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
|
||||
let bucket = exported("paint.bucket", json!({"x": 1, "y": 2}));
|
||||
assert_eq!((get_num(&bucket, "Tlrn"), get_bool(&bucket, "AntA")), (Some(32.0), Some(true)));
|
||||
let canvas = exported("image.canvasSize", json!({"width": 10, "height": 10}));
|
||||
assert_eq!((get_enum(&canvas, "Hrzn").as_deref(), get_enum(&canvas, "Vrtc").as_deref()), (Some("Cntr"), Some("Cntr")));
|
||||
let apply = exported("image.applyImage", json!({}));
|
||||
assert_eq!(get_obj(&apply, "With").and_then(|w| get_enum(w, "Clcl")).as_deref(), Some("Mltp"));
|
||||
let balance = exported("image.adjustments.colorBalance", json!({"midtones": [10, 0, 0]}));
|
||||
assert_eq!(get_bool(&balance, "PrsL"), Some(true));
|
||||
assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3));
|
||||
}
|
||||
|
||||
/// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one.
|
||||
#[test]
|
||||
fn set_colors_exports_only_a_single_colour() {
|
||||
assert!(export_item("tools.setColors", &json!({"foreground": "#ff0000", "background": "#0000ff"}), "S").is_none());
|
||||
assert!(export_item("tools.setColors", &json!({"foregroundCmyk": [0, 0, 0, 100], "background": "#0000ff"}), "S").is_none());
|
||||
assert!(export_item("tools.setColors", &json!({"background": "#0000ff"}), "S").is_some());
|
||||
}
|
||||
|
||||
/// Photoshop's reset of an adjustment layer (`set` with an empty `T`) stays a reset: `kind`
|
||||
/// only joins params that edit something, as any key makes `layer.setAdjustment` merge.
|
||||
#[test]
|
||||
fn adjustment_layer_reset_still_resets() {
|
||||
let set = |t: Descriptor| desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t));
|
||||
let reset = set(desc("HStr").with("presetKind", enm("presetKindType", "presetKindDefault")));
|
||||
let edit = set(desc("ClrB").with("MdtL", D::List(vec![long(10.0), long(0.0), long(0.0)])));
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Reset"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction {
|
||||
name: UnicodeString::new_nul("A"),
|
||||
items: vec![item("setd", "Set", Some(reset)), item("setd", "Set", Some(edit))],
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: vec![],
|
||||
};
|
||||
let steps = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0).steps;
|
||||
assert_eq!(steps[0], ("layer.setAdjustment".to_string(), json!({})));
|
||||
assert_eq!(steps[1].1, json!({"midtones": [10, 0, 0], "kind": "colorBalance"}));
|
||||
}
|
||||
@@ -513,6 +513,29 @@ fn define_brush_from_selection_survives_allocation_failure() {
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_brush_from_selection_survives_failure_of_the_second_allocation() {
|
||||
// Review of #5469: fail_next_for_test trips on the first buffer, so the second fallible
|
||||
// call (the selection-mask read) did not have its own regression. Count the tip buffer as
|
||||
// a clean attempt and fail the mask read instead; both report the same command purpose,
|
||||
// and the command must still bow out before touching anything.
|
||||
let mut s = session(40, 40);
|
||||
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
let pixels_before = surface(&s);
|
||||
let brush_before = s.tools.brush.clone();
|
||||
let presets_before = s.tools.presets.clone();
|
||||
let history_before = s.active().unwrap().history.past_len();
|
||||
crate::allocation::fail_after_for_test(1);
|
||||
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
|
||||
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
|
||||
assert!(result.is_err(), "an allocation failure must be reported, not panic");
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for defining a brush from the selection")));
|
||||
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
|
||||
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
|
||||
assert!(same_pixels(&pixels_before, &surface(&s), s.active().unwrap().doc.bounds()), "the document pixels are unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_on_cmyk_and_16_bit() {
|
||||
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
|
||||
|
||||
@@ -408,12 +408,18 @@ pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result<La
|
||||
Ok(mask)
|
||||
}
|
||||
|
||||
/// Options from the Layers Panel flyout live in preferences so headless/keyboard commands
|
||||
/// behave exactly like pointer-triggered commands. Old preferences preserve Photoshop defaults.
|
||||
pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool {
|
||||
s.prefs().dialogs.get("ui.layersPanelOptions").and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the
|
||||
/// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector
|
||||
/// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its
|
||||
/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768).
|
||||
/// A path that isn't there is an error.
|
||||
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> {
|
||||
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
|
||||
let path = match p.get("path") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?),
|
||||
@@ -424,7 +430,9 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
|
||||
};
|
||||
match path {
|
||||
Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)),
|
||||
None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?),
|
||||
None => {
|
||||
l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -433,12 +441,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
|
||||
pub(crate) const NEW_LAYER_PATH: &str =
|
||||
r##""path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask, or white with no selection)"##;
|
||||
|
||||
/// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask).
|
||||
fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let label = format!("New {} Layer", adj.label());
|
||||
let name = adj.label().to_string();
|
||||
let id = s.edit(&label, |doc, active| {
|
||||
let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj));
|
||||
mask_new_layer(doc, &mut l, p, cmd)?;
|
||||
mask_new_layer(doc, &mut l, p, cmd, true)?;
|
||||
let id = doc.insert_above(*active, l);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
@@ -446,6 +455,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
/// Apply `adj` in place to the targeted layer mask, the targeted alpha channel or Quick Mask, or else the active layer's pixels, as one history step named `label`.
|
||||
fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) -> Result<Value> {
|
||||
if is_mask_target(p) {
|
||||
// The targeted layer mask (#780): ⌘I inverts it, as in Photoshop.
|
||||
@@ -528,7 +538,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"New…",
|
||||
["File"],
|
||||
Some("Cmd+N"),
|
||||
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str}"##,
|
||||
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##,
|
||||
always,
|
||||
|s, p| {
|
||||
use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type};
|
||||
@@ -564,6 +574,18 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
doc.resolution_dpi = res;
|
||||
// Photoshop's New Document › Color Profile: tag the document with another profile
|
||||
// than the working space (#321). Checked before the document opens.
|
||||
if let Some(spec) = p.get("profile").and_then(Value::as_str).filter(|spec| !matches!(*spec, "working" | "none")) {
|
||||
let prof = s.color.resolve(spec, Some(&doc), None)?;
|
||||
if prof.color_space != crate::color_cmds::mode_space(doc.mode) {
|
||||
return Err(EngineError::BadParams {
|
||||
cmd: "file.new".into(),
|
||||
msg: format!("profile `{}` is {:?}; the document is {:?}", prof.description, prof.color_space, doc.mode),
|
||||
});
|
||||
}
|
||||
doc.icc_profile = Some(prof.to_bytes());
|
||||
}
|
||||
let i = s.add_document(doc, None);
|
||||
Ok(json!({ "document": i }))
|
||||
}
|
||||
@@ -616,6 +638,10 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("select.deselect", "Deselect", ["Select"], Some("Cmd+D"), "{}", has_selection, |s, _| {
|
||||
// Nothing selected: nothing to do and no history step, as in Photoshop (#6744).
|
||||
if s.active().is_none_or(|d| d.doc.selection.is_none()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.edit("Deselect", |doc, _| {
|
||||
doc.selection = None;
|
||||
Ok(())
|
||||
@@ -751,8 +777,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers");
|
||||
}
|
||||
let id = layer_param(s, p)?;
|
||||
let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
|
||||
let nid = s.edit("Duplicate Layer", |doc, active| {
|
||||
let mut dup = layer_copy(doc, id)?;
|
||||
let mut dup = layer_copy(doc, id, add_copy)?;
|
||||
if let Some(name) = &name {
|
||||
dup.name.clone_from(name);
|
||||
}
|
||||
@@ -1028,10 +1055,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?;
|
||||
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
|
||||
let id = s.edit("New Color Fill Layer", |doc, active| {
|
||||
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
|
||||
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
|
||||
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?;
|
||||
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?;
|
||||
let id = doc.insert_above(*active, l);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
@@ -1052,6 +1080,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?;
|
||||
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
|
||||
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
|
||||
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
|
||||
let id = s.edit("New Gradient Fill Layer", |doc, active| {
|
||||
let fill = Fill::gradient(
|
||||
vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
|
||||
@@ -1062,7 +1091,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
)
|
||||
.in_mode(doc.mode);
|
||||
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
|
||||
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?;
|
||||
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?;
|
||||
let id = doc.insert_above(*active, l);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
@@ -1698,12 +1727,14 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
/// A copy of layer `id` as Layer › Duplicate Layer makes it, not yet in the document: a fresh id,
|
||||
/// "… copy" appended to the name, and a copy of the Background layer is an ordinary, unlocked
|
||||
/// layer (Photoshop).
|
||||
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
|
||||
pub(crate) fn layer_copy(doc: &Document, id: LayerId, add_copy: bool) -> Result<Layer> {
|
||||
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
|
||||
let from_background = doc.is_background(id);
|
||||
let mut dup = src.duplicate();
|
||||
dup.name = doc.copy_name(&dup.name);
|
||||
if add_copy {
|
||||
dup.name = doc.copy_name(&dup.name);
|
||||
}
|
||||
if from_background {
|
||||
dup.locks = Default::default();
|
||||
}
|
||||
|
||||
@@ -191,6 +191,36 @@ fn smart_filters_reread_only_followed_files() {
|
||||
assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered);
|
||||
}
|
||||
|
||||
/// #7423 (review on #7799): Displace on a Smart Object keeps a `mapPath` as a path, as before
|
||||
/// (only a map from an open document is embedded). A path relative to the saved document, set
|
||||
/// through the filter's own edit, displaces as the absolute one did, and the dialog's "None"
|
||||
/// (`mapDocument: -1`) names no map instead of failing.
|
||||
#[test]
|
||||
fn smart_displace_keeps_a_relative_map_path() {
|
||||
let d = dir("displace-relative");
|
||||
png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]);
|
||||
png(&d.join("map.png"), |_| [255, 255, 255]);
|
||||
let (mut s, id) = document(&d, &d.join("ramp.png"));
|
||||
let path = d.join("map.png").to_string_lossy().into_owned();
|
||||
let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px;
|
||||
s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path, "horizontal": 50, "vertical": 0})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { panic!("a smart object") };
|
||||
(sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).px)
|
||||
};
|
||||
let (params, displaced) = state(&s);
|
||||
assert_eq!(params["mapPath"], json!(path));
|
||||
assert!(photocraft_io::smart_map::displace_embedded_map(¶ms).is_none(), "a path, not an embedded map");
|
||||
assert_ne!(displaced, unfiltered, "the map moves the pixels");
|
||||
s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapPath": "map.png"}})).unwrap();
|
||||
let (params, relative) = state(&s);
|
||||
assert_eq!(params["mapPath"], json!("map.png"));
|
||||
assert_eq!(relative, displaced, "a relative map next to the document");
|
||||
s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapDocument": -1}})).unwrap();
|
||||
assert_eq!(state(&s).1, displaced, "\"None\" changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_sets_and_footage_read_only_followed_files() {
|
||||
let d = dir("datasets");
|
||||
|
||||
@@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
"name" => (value.is_string(), "a string".into()),
|
||||
"profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()),
|
||||
_ => return Err(bad(cmd, format!("unknown parameter `{key}`"))),
|
||||
};
|
||||
if !valid {
|
||||
@@ -101,6 +102,10 @@ pub struct DocumentSettings {
|
||||
pub background: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_color: Option<[f32; 3]>,
|
||||
/// `file.new`'s Color Profile (#321): a built-in profile id or an .icc path; absent is the
|
||||
/// working space, as in presets saved before it existed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
/// Display units only; width/height are always pixels and resolution is always ppi.
|
||||
pub unit: String,
|
||||
pub resolution_unit: String,
|
||||
@@ -127,6 +132,9 @@ impl DocumentSettings {
|
||||
if self.background_color.is_some_and(|c| !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))) {
|
||||
return Err("Background colour components must be within 0..1".into());
|
||||
}
|
||||
if self.profile.as_deref().is_some_and(|p| p.trim().is_empty()) {
|
||||
return Err("A saved Color Profile needs a profile id or path".into());
|
||||
}
|
||||
if !matches!(self.unit.as_str(), "px" | "in" | "cm" | "mm" | "pt" | "pica") || !matches!(self.resolution_unit.as_str(), "in" | "cm") {
|
||||
return Err("Unknown dimension or resolution display unit".into());
|
||||
}
|
||||
@@ -170,6 +178,9 @@ impl DocumentSettings {
|
||||
if let Some(c) = self.background_color {
|
||||
p["backgroundColor"] = json!(c);
|
||||
}
|
||||
if let Some(profile) = &self.profile {
|
||||
p["profile"] = json!(profile);
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
@@ -358,6 +369,27 @@ mod tests {
|
||||
next
|
||||
}
|
||||
|
||||
/// #321: a preset keeps New Document's Color Profile through a reload and back into
|
||||
/// `file.new`; one saved without it (or before it existed) stays on the working space.
|
||||
#[test]
|
||||
fn the_color_profile_survives_save_reload_and_file_new() {
|
||||
let mut s = Session::new();
|
||||
let mut config = settings();
|
||||
config["mode"] = json!("cmyk");
|
||||
config["profile"] = json!("coated-cmyk");
|
||||
let preset = saved(&mut s, "Press", config.clone());
|
||||
assert_eq!(preset["settings"]["profile"], "coated-cmyk");
|
||||
let next = reload(&s);
|
||||
let params = next.presets.documents[0].settings.command_params();
|
||||
assert_eq!(params["profile"], "coated-cmyk");
|
||||
let mut s2 = Session::new();
|
||||
s2.execute("file.new", params).unwrap();
|
||||
assert!(s2.active().unwrap().doc.icc_profile.is_some());
|
||||
let plain = saved(&mut s, "Plain", settings());
|
||||
assert!(plain["settings"].get("profile").is_none());
|
||||
assert!(s.presets.documents[1].settings.command_params().get("profile").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_survives_save_reload_and_file_new() {
|
||||
for mode in ["rgb", "gray", "cmyk", "lab"] {
|
||||
@@ -495,6 +527,9 @@ mod tests {
|
||||
("backgroundColor", json!([2, 0, 0])),
|
||||
("unit", json!("yards")),
|
||||
("resolutionUnit", json!("px")),
|
||||
("profile", json!("")),
|
||||
("profile", json!(" \t")),
|
||||
("profile", json!(12)),
|
||||
] {
|
||||
let mut config = settings();
|
||||
config[key] = bad;
|
||||
|
||||
@@ -131,10 +131,15 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The effective whole-pixel Stroke width, shared by execution and ATN export.
|
||||
pub(crate) fn stroke_width(p: &Value) -> usize {
|
||||
// Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px).
|
||||
p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize
|
||||
}
|
||||
|
||||
/// Edit › Stroke: a band along the selection edge (or the layer's opaque edge without a selection).
|
||||
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px).
|
||||
let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize;
|
||||
let width = stroke_width(p);
|
||||
let color = color_param("edit.stroke", p, "color", s.tools.foreground)?;
|
||||
// A colour picked as CMYK strokes a CMYK document with the picked inks (#4196).
|
||||
let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]);
|
||||
@@ -278,57 +283,6 @@ fn average(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn hash(x: i32, y: i32, seed: u32) -> f32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0x5bd1_e995);
|
||||
h ^= h >> 15;
|
||||
(h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32
|
||||
}
|
||||
|
||||
/// Smooth value noise, summed over octaves (fBm), in 0..1.
|
||||
pub(crate) fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 {
|
||||
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0);
|
||||
for o in 0..8u32 {
|
||||
let (fx, fy) = (x * freq, y * freq);
|
||||
let (ix, iy) = (fx.floor() as i32, fy.floor() as i32);
|
||||
let (tx, ty) = (fx - ix as f32, fy - iy as f32);
|
||||
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
|
||||
let s = seed.wrapping_add(o * 7919);
|
||||
let top = hash(ix, iy, s) + (hash(ix + 1, iy, s) - hash(ix, iy, s)) * sx;
|
||||
let bot = hash(ix, iy + 1, s) + (hash(ix + 1, iy + 1, s) - hash(ix, iy + 1, s)) * sx;
|
||||
sum += (top + (bot - top) * sy) * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
freq *= 2.0;
|
||||
}
|
||||
// Stretch the bell-shaped sum so the clouds use the full foreground→background range.
|
||||
((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Filter › Render › Clouds / Difference Clouds, between the foreground and background colours.
|
||||
fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result<Value> {
|
||||
let (fg, bg) = (s.tools.foreground, s.tools.background);
|
||||
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0) as u32;
|
||||
let size = s.active().map_or(256.0, |d| d.doc.size.width.max(d.doc.size.height) as f32);
|
||||
let base = (size / 4.0).clamp(16.0, 512.0);
|
||||
map_pixels(s, if difference { "Difference Clouds" } else { "Clouds" }, p, |area, px, _| {
|
||||
let w = area.width() as usize;
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
let (x, y) = ((area.x0 + (i % w) as i32) as f32, (area.y0 + (i / w) as i32) as f32);
|
||||
let t = clouds_value(x, y, base, seed);
|
||||
let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t);
|
||||
if difference {
|
||||
for k in 0..3 {
|
||||
p[k] = (p[k] - c[k]).abs();
|
||||
}
|
||||
} else {
|
||||
*p = [c[0], c[1], c[2], 1.0];
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- transform presets ----------
|
||||
|
||||
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
|
||||
@@ -1057,8 +1011,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
show_only
|
||||
),
|
||||
spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average),
|
||||
spec!("filter.render.clouds", "Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, false)),
|
||||
spec!("filter.render.differenceClouds", "Difference Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, true)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1541,6 +1493,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection masks Clouds but doesn't change their size: inside it they are the canvas's
|
||||
/// clouds, as when Clouds sized itself by the document (before it became a filter).
|
||||
#[test]
|
||||
fn clouds_in_a_selection_are_the_canvas_clouds() {
|
||||
// Big enough that the canvas (cloud size 100 px) and the selection (16 px) size differently.
|
||||
let big = |depth: u32| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 300, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s
|
||||
};
|
||||
for depth in [8, 16, 32] {
|
||||
let mut whole = big(depth);
|
||||
whole.execute("filter.render.clouds", json!({"seed": 4})).unwrap();
|
||||
let mut s = big(depth);
|
||||
s.execute("select.rect", json!({"x": 50, "y": 40, "width": 60, "height": 50})).unwrap();
|
||||
s.execute("filter.render.clouds", json!({"seed": 4})).unwrap();
|
||||
for (x, y) in [(50, 40), (75, 60), (100, 85), (109, 89)] {
|
||||
let (a, b) = (pixel(&s, x, y), pixel(&whole, x, y));
|
||||
assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "depth {depth} at {x},{y}: {a:?} vs {b:?}");
|
||||
}
|
||||
assert_eq!(pixel(&s, 300, 200)[3], 0.0, "outside the selection the layer stays empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_and_clouds() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
let mut out: Vec<String> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()).is_some_and(|x| OPENABLE.contains(&x.to_ascii_lowercase().as_str())))
|
||||
.filter(|p| p.is_file() && openable_name(&p.to_string_lossy()))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -116,12 +116,20 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
|
||||
}
|
||||
|
||||
/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, or GIMP's
|
||||
/// gzipped `.xcf.gz`, whose last extension alone says nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn openable_name(path: &str) -> bool {
|
||||
let lower = file_name(path).to_ascii_lowercase();
|
||||
lower.ends_with(".xcf.gz") || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext))
|
||||
}
|
||||
|
||||
/// Extensions the batch commands pick up from a folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const OPENABLE: &[&str] = &[
|
||||
"pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm",
|
||||
"ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto",
|
||||
"afpub",
|
||||
"pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi",
|
||||
"ico", "pnm", "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign",
|
||||
"afphoto", "afpub",
|
||||
];
|
||||
|
||||
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
|
||||
@@ -802,7 +810,7 @@ fn exposure_text(t: f64) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let r = 1.0 / t;
|
||||
if t < 1.0 && (r - r.round()).abs() <= r * 0.01 {
|
||||
if t < 1.0 && r.round() > 1.0 && (r - r.round()).abs() <= r * 0.01 {
|
||||
return Some(format!("1/{} s", r.round()));
|
||||
}
|
||||
Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 })))
|
||||
|
||||
@@ -1458,3 +1458,22 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
|
||||
assert_eq!(std::fs::read(&first).unwrap(), before[0]);
|
||||
assert_eq!(std::fs::read(&second).unwrap(), before[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_text_near_one_second_is_not_one_over_one() {
|
||||
assert_eq!(exposure_text(0.996).as_deref(), Some("1 s"));
|
||||
assert_eq!(exposure_text(0.99).as_deref(), Some("0.99 s"));
|
||||
assert_eq!(exposure_text(1.0 / 250.0).as_deref(), Some("1/250 s"));
|
||||
}
|
||||
|
||||
/// A folder batch picks up GIMP's gzipped `.xcf.gz`, whose last extension alone (`gz`) says nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() {
|
||||
assert!(super::openable_name("/scans/page 01.xcf.gz"));
|
||||
assert!(super::openable_name("C:\\scans\\PAGE.XCF.GZ"));
|
||||
assert!(super::openable_name("page.xcfgz"));
|
||||
assert!(super::openable_name("page.PNG"));
|
||||
assert!(!super::openable_name("archive.tar.gz"));
|
||||
assert!(!super::openable_name("notes.txt"));
|
||||
}
|
||||
@@ -234,7 +234,10 @@ pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), Strin
|
||||
pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
validate_params(id, p)?;
|
||||
// Session state some filters read (colours) becomes explicit params; external inputs resolve here.
|
||||
let prepared = crate::filters_ext::prepare(s, id, p);
|
||||
let mut prepared = crate::filters_ext::prepare(s, id, p);
|
||||
if id == "filter.distort.displace" {
|
||||
prepared = crate::filters_ext::displace_request(&prepared);
|
||||
}
|
||||
let p = &prepared;
|
||||
let mut fp = params_for(id, p).ok_or_else(|| EngineError::Other(format!("unknown filter {id}")))?;
|
||||
crate::filters_ext::resolve(s, &mut fp, p)?;
|
||||
@@ -262,9 +265,10 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
};
|
||||
let selection = doc.selection.clone();
|
||||
let sel_bounds = selection.as_ref().map(photocraft_raster::Surface::content_bounds);
|
||||
// Distortions centre on the selection when there is one, else the canvas.
|
||||
// Distortions centre on the selection when there is one, else the canvas. Clouds and
|
||||
// Fibers place and size their pattern by the canvas either way; the selection masks them.
|
||||
let doc_bounds = doc.bounds();
|
||||
let bounds = sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds);
|
||||
let bounds = if fp.sizes_by_canvas() { doc_bounds } else { sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds) };
|
||||
// Neighbourhood filters repeat edge pixels at the canvas edge (or past it, where the
|
||||
// layer has off-canvas pixels) instead of fading in transparency, as Photoshop does.
|
||||
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(layer), &p)? {
|
||||
@@ -297,9 +301,24 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) {
|
||||
return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into()));
|
||||
}
|
||||
// A smart filter re-renders from its params alone: a Displace map read from an
|
||||
// open document, which the re-render can't read again, is kept in them, as
|
||||
// Photoshop embeds it (#7423). A `mapPath` stays a path, read next to the
|
||||
// document on each re-render.
|
||||
let params = match &fp {
|
||||
FilterParams::Displace { map: Some(map), .. }
|
||||
if photocraft_io::smart_map::displace_embedded_map(¶ms).is_none() && crate::filters_ext::map_path(¶ms).is_none() =>
|
||||
{
|
||||
photocraft_io::smart_map::displace_embed_map(¶ms, map).ok_or_else(|| {
|
||||
EngineError::Other(
|
||||
"Displace: the displacement map is too large to keep in a Smart Object's filter: rasterize the layer first".into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
_ => params.clone(),
|
||||
};
|
||||
// Non-destructive: record the filter and re-render the smart object from its source.
|
||||
let sf =
|
||||
SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
let sf = SmartFilter { command: id.clone(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
return crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref()).map(|()| fp);
|
||||
}
|
||||
_ => return Err(EngineError::Other("not a pixel layer".into())),
|
||||
@@ -934,4 +953,65 @@ mod tests {
|
||||
assert!(calls.load(std::sync::atomic::Ordering::SeqCst) > 2);
|
||||
assert!(new.tiles().all(|(c, _)| got.tile(*c).is_some()));
|
||||
}
|
||||
|
||||
/// #7423: Displace on a Smart Object keeps its map (read from an open document) in the smart
|
||||
/// filter, as Photoshop embeds it, so the filter displaces and keeps displacing when the
|
||||
/// object re-renders. A map chosen again replaces it.
|
||||
#[test]
|
||||
fn displace_on_a_smart_object_embeds_its_map() {
|
||||
let mut s = Session::new();
|
||||
// Documents 0 and 1: a white and a black displacement map.
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "background": "black"})).unwrap();
|
||||
s.execute("file.new", json!({"width": 48, "height": 32, "background": "transparent"})).unwrap();
|
||||
s.edit("square", |doc, active| {
|
||||
let fmt = doc.pixel_format();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
surf.fill_rect(photocraft_geom::Rect::new(10, 10, 20, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
let original = {
|
||||
let d = s.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
|
||||
sm.cache.as_ref().unwrap().read_region(d.doc.bounds())
|
||||
};
|
||||
let smart = |s: &Session| {
|
||||
let d = s.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
|
||||
(sm.smart_filters[0].params.clone(), sm.cache.as_ref().unwrap().read_region(d.doc.bounds()))
|
||||
};
|
||||
let first_value = |params: &Value| photocraft_io::smart_map::displace_embedded_map(params).expect("an embedded map").data[0];
|
||||
|
||||
s.execute("filter.distort.displace", json!({"horizontal": 10, "vertical": 0, "mapDocument": 0})).unwrap();
|
||||
let (params, displaced) = smart(&s);
|
||||
assert!(params.get("mapDocument").is_none(), "{params}");
|
||||
assert_eq!(first_value(¶ms), 1.0, "the white map");
|
||||
assert_ne!(displaced, original, "the smart object is displaced");
|
||||
// Re-rendered from its params alone (the filter hidden and shown again): still displaced.
|
||||
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
|
||||
assert_eq!(smart(&s).1, original);
|
||||
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
|
||||
assert_eq!(smart(&s).1, displaced);
|
||||
|
||||
// Another map chosen for the filter replaces the embedded one.
|
||||
s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": {"mapDocument": 1}})).unwrap();
|
||||
let (params, again) = smart(&s);
|
||||
assert!(params.get("mapDocument").is_none(), "{params}");
|
||||
assert_eq!(first_value(¶ms), 0.0, "the black map");
|
||||
assert_ne!(again, displaced, "displaced the other way");
|
||||
|
||||
// The stored params passed back whole (the old embedded map included) with another map
|
||||
// named: the named map wins, for the filter's own edit and for a new Displace alike.
|
||||
let mut back = params.as_object().unwrap().clone();
|
||||
back.insert("mapDocument".into(), json!(0));
|
||||
s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": back.clone()})).unwrap();
|
||||
assert_eq!(first_value(&smart(&s).0), 1.0, "the white map, not the embedded black one");
|
||||
back.insert("mapDocument".into(), json!(1));
|
||||
s.execute("filter.distort.displace", Value::Object(back)).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
|
||||
assert_eq!(first_value(&sm.smart_filters[1].params), 0.0, "the black map, not the passed-back white one");
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,12 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
||||
},
|
||||
},
|
||||
// ---- Render ----
|
||||
"filter.render.clouds" | "filter.render.differenceClouds" => FilterParams::Clouds {
|
||||
seed: seed(p),
|
||||
difference: id == "filter.render.differenceClouds",
|
||||
foreground: colour(p, "foreground", BLACK),
|
||||
background: colour(p, "background", WHITE),
|
||||
},
|
||||
"filter.render.fibers" => FilterParams::Fibers {
|
||||
variance: f(p, "variance", 16.0).clamp(1.0, 64.0),
|
||||
strength: f(p, "strength", 4.0).clamp(1.0, 64.0),
|
||||
@@ -401,7 +407,8 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
||||
|
||||
/// Ids whose rendering uses the foreground/background colours.
|
||||
fn uses_colours(id: &str) -> bool {
|
||||
matches!(id, "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize") || crate::gallery_cmds::uses_colours(id)
|
||||
matches!(id, "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize")
|
||||
|| crate::gallery_cmds::uses_colours(id)
|
||||
}
|
||||
|
||||
/// Fills in session state a filter reads (the current colours) as explicit params, so the
|
||||
@@ -450,7 +457,7 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
|
||||
let layer = p.get("mapLayer").and_then(Value::as_u64);
|
||||
let img = if let Some(path) = map_path(p) {
|
||||
map_from_path(path, layer)?
|
||||
} else if let Some(d) = p.get("mapDocument") {
|
||||
} else if let Some(d) = map_document(p) {
|
||||
let docs = s.documents();
|
||||
let found = match d {
|
||||
Value::String(name) => docs.iter().find(|ds| ds.doc.name == *name || ds.path.as_deref() == Some(name.as_str())),
|
||||
@@ -465,10 +472,23 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_path(p: &Value) -> Option<&str> {
|
||||
/// Displace's params as a request for its map: a map named in them (`mapPath` or `mapDocument`)
|
||||
/// wins over the one already embedded, which is read only when none is named. Otherwise a dialog
|
||||
/// or agent passing the filter's stored params back with a new map got the old map again.
|
||||
pub(crate) fn displace_request(p: &Value) -> Value {
|
||||
if map_path(p).is_some() || map_document(p).is_some() { photocraft_io::smart_map::displace_without_map(p) } else { p.clone() }
|
||||
}
|
||||
|
||||
pub(crate) fn map_path(p: &Value) -> Option<&str> {
|
||||
p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Displace's `mapDocument`: an index or a name. Null and a negative index (the dialog's "None")
|
||||
/// name no document.
|
||||
pub(crate) fn map_document(p: &Value) -> Option<&Value> {
|
||||
p.get("mapDocument").filter(|d| !d.is_null() && !d.as_i64().is_some_and(|i| i < 0))
|
||||
}
|
||||
|
||||
/// Displace's map read from a file (flattened, or its `mapLayer`).
|
||||
fn map_from_path(path: &str, layer: Option<u64>) -> Result<Option<algo::Image>> {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
|
||||
@@ -628,6 +648,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
r##"{"amount":-100..100=10,"ridges":0..20=5,"style":"pondRipples|outFromCenter|aroundCenter"}"##
|
||||
),
|
||||
// Render
|
||||
cmd!("filter.render.clouds", "Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##),
|
||||
cmd!("filter.render.differenceClouds", "Difference Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##),
|
||||
cmd!(
|
||||
"filter.render.fibers",
|
||||
"Fibers…",
|
||||
|
||||
+117
-29
@@ -32,6 +32,7 @@ pub struct CutParts {
|
||||
pub layer: LayerId,
|
||||
rest: Surface,
|
||||
piece: Surface,
|
||||
piece_bounds: photocraft_geom::Rect,
|
||||
/// The pieces are the layer's mask (`"target":"mask"`), not its pixels.
|
||||
mask: bool,
|
||||
}
|
||||
@@ -55,41 +56,27 @@ impl CutParts {
|
||||
};
|
||||
let fmt = surf.format();
|
||||
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let full = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
|
||||
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
|
||||
let mut rest = if mask { surf.clone() } else { full.clone() };
|
||||
let base = if mask || fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
|
||||
let mut rest = base.clone();
|
||||
let mut piece = Surface::new(with_alpha);
|
||||
let fill = !copy && (mask || doc.is_background(layer));
|
||||
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
|
||||
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
|
||||
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { base.content_bounds() });
|
||||
if !b.is_empty() {
|
||||
let n = with_alpha.channels();
|
||||
let a = n - 1;
|
||||
let mut rp = full.read_region(b);
|
||||
let mut pp = rp.clone();
|
||||
let w = b.width() as usize;
|
||||
for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() {
|
||||
let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0);
|
||||
if k <= 0.0 {
|
||||
p.fill(0.0);
|
||||
} else {
|
||||
p[a] *= k;
|
||||
}
|
||||
if !copy && !fill {
|
||||
r[a] *= 1.0 - k;
|
||||
}
|
||||
}
|
||||
if !mask {
|
||||
rest.write_region(b, &rp);
|
||||
}
|
||||
piece = cut_piece(&base, sel, b, with_alpha)?;
|
||||
if fill {
|
||||
crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?;
|
||||
let opaque_bg = [background[0], background[1], background[2], 1.0];
|
||||
crate::pixels::try_fill_surface(&mut rest, b, opaque_bg, Some(sel), false)?;
|
||||
if mask {
|
||||
rest.prune();
|
||||
}
|
||||
} else if !copy && !mask {
|
||||
crate::pixels::try_lift_coverage(&mut rest, b, sel)?;
|
||||
}
|
||||
rest.prune();
|
||||
piece.write_region(b, &pp);
|
||||
piece.prune();
|
||||
}
|
||||
Ok(Self { layer, rest, piece, mask })
|
||||
let piece_bounds = piece.content_bounds();
|
||||
Ok(Self { layer, rest, piece, piece_bounds, mask })
|
||||
}
|
||||
|
||||
/// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer.
|
||||
@@ -107,10 +94,14 @@ impl CutParts {
|
||||
} else {
|
||||
l.surface_mut().ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?
|
||||
};
|
||||
if self.piece_bounds.is_empty() {
|
||||
*surf = self.rest.clone();
|
||||
return Ok(());
|
||||
}
|
||||
let mut out = self.rest.clone();
|
||||
let piece = photocraft_algo::resample::translate_surface(&self.piece, dx, dy);
|
||||
let piece = self.piece.translated(dx, dy, self.piece_bounds);
|
||||
if self.mask {
|
||||
crate::fill_cmds::composite_over(&mut out, &piece, piece.content_bounds(), None)?;
|
||||
crate::fill_cmds::composite_over(&mut out, &piece, self.piece_bounds.translate(dx, dy), None)?;
|
||||
} else {
|
||||
crate::transform_cmds::composite_over(&mut out, &piece);
|
||||
}
|
||||
@@ -119,6 +110,103 @@ impl CutParts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the selected pixels of `base` over `b` into a `with_alpha` surface, tile by tile in
|
||||
/// parallel. Fully selected tiles on an alpha surface share `base`'s `Arc<Tile>` directly.
|
||||
fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha: PixelFormat) -> Result<Surface> {
|
||||
use photocraft_color::{SampleType, read_sample};
|
||||
use photocraft_geom::{TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Tile;
|
||||
use rayon::prelude::*;
|
||||
|
||||
let mut piece = Surface::new(with_alpha);
|
||||
let src_fmt = base.format();
|
||||
let same_fmt = src_fmt == with_alpha;
|
||||
let n = with_alpha.channels();
|
||||
let a = n - 1;
|
||||
let dst_bpp = with_alpha.bytes_per_pixel();
|
||||
let src_bpp = src_fmt.bytes_per_pixel();
|
||||
let sb = with_alpha.sample.bytes();
|
||||
let alpha_off = dst_bpp - sb;
|
||||
let src_empty_default = same_fmt && base.default_bytes().iter().all(|&v| v == 0);
|
||||
let oom = |e| EngineError::Other(format!("not enough memory while floating selection ({e}); the document was not changed"));
|
||||
let mut coords = crate::allocation::capacity(b.tiles().count(), "floating selection")?;
|
||||
coords.extend(b.tiles());
|
||||
let tiles = coords
|
||||
.into_par_iter()
|
||||
.map(|tc| -> Result<Option<(TileCoord, Arc<Tile>)>> {
|
||||
let tr = tc.rect().intersect(&b);
|
||||
let mask = crate::fill_cmds::Mask::of(sel, tc);
|
||||
if mask.empty(tr) {
|
||||
return Ok(None);
|
||||
}
|
||||
let src_tile = base.tile(tc);
|
||||
if same_fmt && src_tile.is_none() && src_empty_default {
|
||||
return Ok(None);
|
||||
}
|
||||
if same_fmt
|
||||
&& tr == tc.rect()
|
||||
&& mask.full(tr)
|
||||
&& let Some(t) = src_tile
|
||||
{
|
||||
return Ok(Some((tc, t.clone())));
|
||||
}
|
||||
let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes());
|
||||
let mut out = piece.try_default_tile().map_err(oom)?;
|
||||
let out_bytes = out.bytes_mut();
|
||||
let mut px = [0.0f32; 8];
|
||||
let mut has_content = false;
|
||||
for y in tr.y0..tr.y1 {
|
||||
let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize;
|
||||
for x in tr.x0..tr.x1 {
|
||||
let k = mask.at(x, y);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let idx = row + (x - tc.tx * TILE_SIZE) as usize;
|
||||
let dst_i = idx * dst_bpp;
|
||||
let Some(dst_px) = out_bytes.get_mut(dst_i..dst_i + dst_bpp) else { continue };
|
||||
let src_i = if src_bytes.len() == src_bpp { 0 } else { idx * src_bpp };
|
||||
let Some(src_px) = src_bytes.get(src_i..src_i + src_bpp) else { continue };
|
||||
if same_fmt && k >= 1.0 {
|
||||
if src_px.iter().any(|&v| v != 0) {
|
||||
dst_px.copy_from_slice(src_px);
|
||||
has_content = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if same_fmt {
|
||||
for (c, v) in px.iter_mut().enumerate().take(n) {
|
||||
*v = read_sample(src_px, src_fmt.sample, c);
|
||||
}
|
||||
// The selection lifts its share of a pixel's coverage, up to all of it
|
||||
// (Photoshop): a layer's own transparency, loaded as the selection, lifts
|
||||
// it whole (#7655). An opaque pixel splits as k and 1 − k.
|
||||
px[a] = px[a].min(k);
|
||||
} else {
|
||||
for (c, v) in px.iter_mut().enumerate().take(a) {
|
||||
*v = read_sample(src_px, src_fmt.sample, c);
|
||||
}
|
||||
px[a] = k;
|
||||
}
|
||||
if px[a] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
photocraft_raster::encode_pixel(&with_alpha, &px[..n], dst_px);
|
||||
let non_zero = match with_alpha.sample {
|
||||
SampleType::U8 => dst_px.get(alpha_off) != Some(&0) || dst_px.iter().any(|&v| v != 0),
|
||||
SampleType::U16 => dst_px.get(alpha_off..dst_bpp) != Some(&[0, 0]) || dst_px.iter().any(|&v| v != 0),
|
||||
SampleType::F32 => dst_px.iter().any(|&v| v != 0),
|
||||
};
|
||||
has_content |= non_zero;
|
||||
}
|
||||
}
|
||||
Ok(has_content.then(|| (tc, Arc::new(out))))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
piece.put_tiles(tiles.into_iter().flatten());
|
||||
Ok(piece)
|
||||
}
|
||||
|
||||
/// A piece floating `offset` whole pixels from where it was cut, over document `revision`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Floating {
|
||||
|
||||
@@ -226,3 +226,113 @@ fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colo
|
||||
assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour");
|
||||
assert_eq!(mask_at(&s, 40, 12), 0.0);
|
||||
}
|
||||
|
||||
/// #7655: a selection loaded from the layer's own transparency lifts its half-transparent pixels
|
||||
/// whole, leaving nothing behind (Photoshop); a half selection of an opaque pixel still splits it.
|
||||
#[test]
|
||||
fn a_layers_own_transparency_floats_its_soft_pixels_whole() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("paint", |doc, _| {
|
||||
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
surf.fill_rect(Rect::new(10, 10, 20, 30), &[1.0, 0.0, 0.0, 0.5]);
|
||||
surf.fill_rect(Rect::new(20, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap();
|
||||
s.execute("select.float", json!({"dx": 0, "dy": 25})).unwrap();
|
||||
s.execute("select.drop", json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!(alpha(d, id, 15, 20), 0.0, "no ghost where the soft pixels were");
|
||||
assert!((alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "they moved whole: {}", alpha(d, id, 15, 45));
|
||||
assert_eq!((alpha(d, id, 25, 20), alpha(d, id, 25, 45)), (0.0, 1.0));
|
||||
|
||||
// A copy (⌥) of them is as solid as they are, as Photoshop's Layer via Copy of the same
|
||||
// selection is, and leaves them where they were.
|
||||
s.undo();
|
||||
s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap();
|
||||
s.execute("select.float", json!({"dx": 0, "dy": 25, "copy": true})).unwrap();
|
||||
s.execute("select.drop", json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!((alpha(d, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "{} {}", alpha(d, id, 15, 20), alpha(d, id, 15, 45));
|
||||
|
||||
// A half selection of an opaque pixel splits it half and half, as before.
|
||||
let (mut s, id) = session();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.edit("half", |doc, _| {
|
||||
let sel = doc.selection.as_mut().unwrap();
|
||||
sel.fill_rect(doc_rect(80, 60), &[0.5]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
|
||||
s.execute("select.drop", json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!((alpha(d, id, 15, 15) - 0.5).abs() < 0.01 && (alpha(d, id, 55, 15) - 0.5).abs() < 0.01);
|
||||
}
|
||||
|
||||
fn doc_rect(w: i32, h: i32) -> Rect {
|
||||
Rect::new(0, 0, w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_shares_interior_tiles_on_cut_and_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
||||
s.tools.background = [0.2, 0.4, 0.8, 1.0];
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
// Paint non-uniform pixels so original tiles are distinct.
|
||||
s.edit("pattern", |doc, _| {
|
||||
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
for ty in 0..4 {
|
||||
for tx in 0..4 {
|
||||
surf.write_pixel(tx * 256 + 5, ty * 256 + 5, &[tx as f32 * 0.25, ty as f32 * 0.25, 0.5, 1.0]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// Select (100..900)^2, which fully covers interior tiles (1, 1) and (2, 2).
|
||||
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 800, "height": 800})).unwrap();
|
||||
s.execute("select.float", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let orig = st.doc.layer(id).unwrap().surface().unwrap();
|
||||
let parts = &floating(st).unwrap().parts;
|
||||
let tc1 = photocraft_geom::TileCoord { tx: 1, ty: 1 };
|
||||
let tc2 = photocraft_geom::TileCoord { tx: 2, ty: 2 };
|
||||
// Interior tiles of `piece` share the original layer's Arc<Tile>.
|
||||
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), parts.piece.tile(tc1).unwrap()));
|
||||
assert!(Arc::ptr_eq(orig.tile(tc2).unwrap(), parts.piece.tile(tc2).unwrap()));
|
||||
// Interior tiles of the Background hole in `rest` share one solid fill Arc<Tile>.
|
||||
assert!(Arc::ptr_eq(parts.rest.tile(tc1).unwrap(), parts.rest.tile(tc2).unwrap()));
|
||||
// Moving by a whole tile (256, 0) shares the interior tile directly in the preview document.
|
||||
let shown = displayed(st, (256, 0)).unwrap();
|
||||
let shown_surf = shown.layer(id).unwrap().surface().unwrap();
|
||||
let moved_tc = photocraft_geom::TileCoord { tx: 2, ty: 1 };
|
||||
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), shown_surf.tile(moved_tc).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_feathered_selection_preserves_partial_alpha_across_depths() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 120, "height": 80, "background": "transparent", "depth": depth})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("paint", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 55, 80), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30, "feather": 4.0})).unwrap();
|
||||
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
|
||||
s.execute("select.drop", json!({})).unwrap();
|
||||
let surf = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
|
||||
// Center of moved piece is opaque red; center of hole is transparent; feathered edge has partial alpha.
|
||||
assert!(surf.rgba(75, 35)[3] > 0.95, "{depth}-bit moved center");
|
||||
assert!(surf.rgba(35, 35)[3] < 0.05, "{depth}-bit hole center");
|
||||
let edge_a = surf.rgba(60, 35)[3];
|
||||
assert!(edge_a > 0.1 && edge_a < 0.9, "{depth}-bit feathered edge alpha={edge_a}");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside
|
||||
//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
|
||||
//! Layers panel: showing or hiding a layer's effects and Smart Filters under its row (the
|
||||
//! shared disclosure triangle). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
|
||||
//! clean document stays clean.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use photocraft_doc::{Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
@@ -10,15 +10,23 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
const ID: &str = "layer.setEffectsExpanded";
|
||||
|
||||
/// A `layer.setEffectsExpanded` parameter error.
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// The command is available while some layer has effects or Smart Filters to disclose.
|
||||
fn has_effects(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) }
|
||||
if d.doc.walk().iter().any(|(_, _, l)| has_children(l)) { Ok(()) } else { Err("no layer has effects or smart filters".into()) }
|
||||
}
|
||||
|
||||
/// Whether the layer has a list to disclose: effects, or Smart Filters on a Smart Object.
|
||||
fn has_children(l: &Layer) -> bool {
|
||||
!l.effects.items.is_empty() || matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty())
|
||||
}
|
||||
|
||||
/// An optional boolean param: `None` when absent or null, an error when it is anything but true or false.
|
||||
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
@@ -37,12 +45,12 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
|
||||
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
|
||||
};
|
||||
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect();
|
||||
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| has_children(l)).map(|(_, _, l)| l.id).collect();
|
||||
let current = match layer {
|
||||
Some(id) => {
|
||||
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if l.effects.items.is_empty() {
|
||||
return Err(bad(format!("layer {} has no effects", id.0)));
|
||||
if !has_children(l) {
|
||||
return Err(bad(format!("layer {} has no effects or smart filters", id.0)));
|
||||
}
|
||||
!st.fx_collapsed.contains(&id)
|
||||
}
|
||||
@@ -59,13 +67,14 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"expanded": expanded, "layers": targets.len()}))
|
||||
}
|
||||
|
||||
/// Register `layer.setEffectsExpanded`: view state, so it is not journaled and adds no undo step.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: ID,
|
||||
label: "Expand/Collapse Effects",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##,
|
||||
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects or smart filters; view state, not an undo step)"##,
|
||||
enabled: has_effects,
|
||||
run: set_expanded,
|
||||
journal: false,
|
||||
@@ -88,10 +97,35 @@ mod tests {
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
/// Whether the layer's effects list is shown, i.e. it is not in the document's collapsed set.
|
||||
fn open(s: &Session, id: u64) -> bool {
|
||||
!s.active().unwrap().fx_collapsed.contains(&LayerId(id))
|
||||
}
|
||||
|
||||
/// Smart Filters hide and show with the layer's effects, as view state: no document edit, revision or history step.
|
||||
#[test]
|
||||
fn smart_filters_share_the_effects_disclosure_without_an_edit() {
|
||||
let (mut s, a, b) = session();
|
||||
s.execute("layer.select", json!({"layer": a})).unwrap();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
let smart = s.active().unwrap().active_layer.unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute(ID, json!({"layer": smart.0, "expanded": false})).unwrap();
|
||||
assert!(!open(&s, smart.0));
|
||||
assert!(open(&s, b));
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc));
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
s.execute(ID, json!({"expanded": false, "all": true})).unwrap();
|
||||
assert!(!open(&s, smart.0) && !open(&s, b));
|
||||
s.execute(ID, json!({"expanded": true, "all": true})).unwrap();
|
||||
assert!(s.active().unwrap().fx_collapsed.is_empty());
|
||||
}
|
||||
|
||||
/// Toggling one layer or all of them flips only the disclosure state: no history step, no duplicates, and the document stays clean.
|
||||
#[test]
|
||||
fn toggles_one_layer_or_all_without_history() {
|
||||
let (mut s, a, b) = session();
|
||||
|
||||
@@ -102,6 +102,9 @@ mod tests {
|
||||
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
|
||||
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Group Layers creates collapsed groups; the tests start from both open.
|
||||
assert!(!expanded(&s, inner) && !expanded(&s, outer));
|
||||
s.execute(ID, json!({"all": true, "expanded": true})).unwrap();
|
||||
(s, inner, outer)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,10 @@ fn select_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let damage = crate::layer_multi_cmds::step_damage(&start, &doc);
|
||||
st.doc = doc;
|
||||
// Stepping only over selection changes keeps a clean document clean (#6865).
|
||||
let keep_clean = st.keeps_clean(&start);
|
||||
crate::restore_target(st, layers);
|
||||
st.revision += 1;
|
||||
st.bump_revision(keep_clean);
|
||||
st.last_damage = damage;
|
||||
if at != target {
|
||||
// Not reached (the bounds were checked): the document is consistent at the state reached.
|
||||
|
||||
@@ -273,6 +273,19 @@ pub(crate) fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32,
|
||||
Err(bad(cmd, format!("{nw} x {nh} px is {mp} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size{hint}")))
|
||||
}
|
||||
|
||||
/// Like [`check_resample_budget`] for a command that, besides resampling the document to
|
||||
/// `nw` x `nh` px, holds `work_bytes_per_px` more bytes per pixel of full-canvas working buffers
|
||||
/// (a composite, a mask, an output layer) at once.
|
||||
pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, work_bytes_per_px: u64, hint: &str) -> Result<()> {
|
||||
let bpp = (doc.pixel_format().bytes_per_pixel().max(1) as u64).saturating_add(work_bytes_per_px);
|
||||
let px = u64::from(nw).saturating_mul(u64::from(nh));
|
||||
if px.saturating_mul(bpp) <= MAX_RESAMPLE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000;
|
||||
Err(bad(cmd, format!("{nw} x {nh} px is {} megapixels; this conversion is limited to {max_mp} megapixels. Choose a smaller size{hint}", px / 1_000_000)))
|
||||
}
|
||||
|
||||
/// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to
|
||||
/// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution
|
||||
/// mode. Callers check [`check_resample_budget`] first.
|
||||
|
||||
@@ -212,6 +212,10 @@ struct Running {
|
||||
label: String,
|
||||
params: Value,
|
||||
journal: bool,
|
||||
/// The recording (`ActionState::take`) running when the job started, if any (set by
|
||||
/// `dispatch`, once recording resumes). Only that recording gets the job's step: one started
|
||||
/// later must not pick up an edit made before it.
|
||||
recorded_by: Option<u64>,
|
||||
doc: Option<DocId>,
|
||||
/// The document as the job saw it: applying fails if it was edited meanwhile.
|
||||
snapshot: Option<Weak<Document>>,
|
||||
@@ -400,6 +404,7 @@ pub fn run<T: Send + 'static>(
|
||||
label: label.to_string(),
|
||||
params: Value::Null,
|
||||
journal: true,
|
||||
recorded_by: None,
|
||||
doc,
|
||||
snapshot,
|
||||
ctx,
|
||||
@@ -532,6 +537,15 @@ impl Session {
|
||||
})?;
|
||||
ctx.progress(0.95, "Preparing layers");
|
||||
warm_layer_bounds(&imported.document, || ctx.check())?;
|
||||
// Type layers need the shared text engine, whose first use waits for the
|
||||
// installed-font scan started at launch (minutes on some machines). Wait here,
|
||||
// so the shell's missing-font check after the open finds it ready instead of
|
||||
// freezing the window (#7332).
|
||||
if crate::type_extra_cmds::has_type_layers(&imported.document) {
|
||||
ctx.progress(0.97, "Loading fonts");
|
||||
let _ = photocraft_text::shared();
|
||||
ctx.check()?;
|
||||
}
|
||||
Ok(imported)
|
||||
},
|
||||
move |s, mut r: photocraft_io::ImportResult| {
|
||||
@@ -748,6 +762,26 @@ impl Session {
|
||||
}
|
||||
// Pixel commands follow the Channels panel target unless the caller names one.
|
||||
let run_params = self.admit(id, spec, ¶ms)?;
|
||||
// This UI-only disclosure preference applies to both `execute` and `start`:
|
||||
// desktop UI normally calls `start`, even for synchronous Layer Style commands.
|
||||
// The layer the command will change, named as the command accepts it (id or name), else the
|
||||
// active one. `layer.layerStyle.clear` needs no case: it leaves no effects, so nothing expands.
|
||||
// Transfer Effects changes its destination (`to`), not the layer it takes the effects from.
|
||||
let new_fx_target = if id.starts_with("layer.layerStyle.") {
|
||||
let target = if id == "layer.layerStyle.transferEffects" {
|
||||
crate::commands::layer_param(self, &json!({"layer": params.get("to").cloned().unwrap_or(Value::Null)}))
|
||||
} else {
|
||||
crate::commands::layer_param(self, ¶ms)
|
||||
}
|
||||
.ok();
|
||||
self.active().and_then(|st| {
|
||||
let target = target?;
|
||||
(st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true);
|
||||
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
|
||||
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
|
||||
self.jobs.spawn = background;
|
||||
@@ -780,6 +814,7 @@ impl Session {
|
||||
job.command = id.to_string();
|
||||
job.params = params;
|
||||
job.journal = spec.journal;
|
||||
job.recorded_by = self.actions.recording.map(|_| self.actions.take);
|
||||
let jid = job.id;
|
||||
self.jobs.running.push(job);
|
||||
Ok(Started::Job(jid))
|
||||
@@ -788,6 +823,17 @@ impl Session {
|
||||
if let Some(before) = before {
|
||||
self.add_relayout_damage(&before);
|
||||
}
|
||||
if let Some((doc_id, layer_id)) = new_fx_target
|
||||
&& let Some(st) = self.active_mut()
|
||||
&& st.doc.id == doc_id
|
||||
&& st.doc.layer(layer_id).is_some_and(|l| !l.effects.items.is_empty())
|
||||
{
|
||||
if expand_new_fx {
|
||||
st.fx_collapsed.retain(|id| *id != layer_id);
|
||||
} else if !st.fx_collapsed.contains(&layer_id) {
|
||||
st.fx_collapsed.push(layer_id);
|
||||
}
|
||||
}
|
||||
self.after_command(id, params, spec.journal, Some(nested_from));
|
||||
Ok(Started::Done(v))
|
||||
}
|
||||
@@ -868,7 +914,13 @@ impl Session {
|
||||
}
|
||||
|
||||
fn apply_job(&mut self, job: &Running, apply: Option<Apply>, payload: Payload) -> Result<Value> {
|
||||
let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, .. } = job;
|
||||
let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, recorded_by, .. } = job;
|
||||
// A recording started after the job (#7289) keeps what it has so far, and the job's step
|
||||
// goes into the journal past its start, so that recording doesn't get it.
|
||||
let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take);
|
||||
if foreign {
|
||||
crate::actions_cmds::flush_recording(self)?;
|
||||
}
|
||||
let prev_active = self.active;
|
||||
let target = match doc {
|
||||
Some(d) => {
|
||||
@@ -917,6 +969,9 @@ impl Session {
|
||||
// to that document instead, when the user switched tabs mid-job.
|
||||
if r.is_ok() {
|
||||
self.after_command(command, params.clone(), *journal, Some(nested_from));
|
||||
if foreign && let Some((start, _)) = self.actions.recording.as_mut() {
|
||||
*start = self.journal.len();
|
||||
}
|
||||
}
|
||||
// Give the user back the layers they selected while the job ran, unless the job chose a
|
||||
// selection itself (a new layer, say) or one of their layers is gone.
|
||||
|
||||
@@ -94,6 +94,36 @@ fn free_transform_runs_as_a_job_with_the_inline_result() {
|
||||
assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_runs_as_a_job_with_the_inline_result() {
|
||||
let arc = json!({"style": "arc", "bend": 30});
|
||||
let mut inline = session(300, 200);
|
||||
inline.execute("edit.transform.warp", arc.clone()).unwrap();
|
||||
|
||||
let mut s = session(300, 200);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let id = job(s.start("edit.transform.warp", arc).unwrap());
|
||||
let e = wait_event(&mut s, id);
|
||||
assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}");
|
||||
assert_eq!(e.label, "Warp");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step");
|
||||
assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_warp_leaves_the_document_unchanged() {
|
||||
let mut s = session(1600, 1200);
|
||||
let doc_before = s.active().unwrap().doc.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let id = job(s.start("edit.transform.warp", json!({"style": "flag", "bend": 40})).unwrap());
|
||||
assert!(s.cancel_job(id));
|
||||
assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled);
|
||||
s.join_cancelled_jobs();
|
||||
let st = s.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &doc_before), "the document snapshot was not replaced");
|
||||
assert_eq!(st.history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_leaves_the_document_byte_identical() {
|
||||
let mut s = session(1600, 1200);
|
||||
@@ -605,3 +635,45 @@ fn an_inline_job_ctx_lets_another_thread_cancel_inline_work() {
|
||||
s.set_inline_job_ctx(None);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
}
|
||||
|
||||
/// The command ids an action holds.
|
||||
fn action_steps(s: &mut Session, name: &str) -> Vec<String> {
|
||||
let a = s.execute("actions.get", json!({"action": name})).unwrap();
|
||||
a["steps"].as_array().unwrap().iter().map(|step| step[0].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A job's result is recorded only by the recording it was started in: one started later keeps
|
||||
/// its own steps around it but doesn't pick up the older edit (#7289).
|
||||
#[test]
|
||||
fn a_job_lands_in_the_recording_it_was_started_in_only() {
|
||||
// Started while recording A, applied after A stopped and B started.
|
||||
let mut s = session(40, 30);
|
||||
s.execute("actions.record", json!({"name": "A"})).unwrap();
|
||||
let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap());
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "B"})).unwrap();
|
||||
// B's own steps before and after the result lands stay in B (a new document, so the job's
|
||||
// document is unchanged and its result applies).
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
s.wait_job(id).unwrap();
|
||||
s.execute("image.adjustments.desaturate", json!({})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert!(action_steps(&mut s, "A").is_empty());
|
||||
assert_eq!(action_steps(&mut s, "B"), ["file.new", "image.adjustments.desaturate"]);
|
||||
|
||||
// Started before any recording, applied while B records.
|
||||
let mut s = session(40, 30);
|
||||
let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap());
|
||||
s.execute("actions.record", json!({"name": "B"})).unwrap();
|
||||
s.wait_job(id).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert!(action_steps(&mut s, "B").is_empty());
|
||||
|
||||
// Started and applied while A records: A has it, as before.
|
||||
let mut s = session(40, 30);
|
||||
s.execute("actions.record", json!({"name": "A"})).unwrap();
|
||||
let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap());
|
||||
s.wait_job(id).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(action_steps(&mut s, "A"), ["filter.blur.gaussianBlur"]);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! changing it never adds a history step. Every structural change below is one history step.
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, VideoData};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -740,21 +740,107 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
|
||||
/// [`step_damage`] but without computing any bounds. Cheap: pixels compare by tile pointer.
|
||||
/// Conservative (`false`) past a nesting depth no real document reaches.
|
||||
pub fn same_pixels(a: &Document, b: &Document) -> bool {
|
||||
fn walk(xs: &[Layer], ys: &[Layer], depth: usize) -> bool {
|
||||
pixels_equal_with(a, b, |_, _| false)
|
||||
}
|
||||
|
||||
/// [`same_pixels`], with video layers equal when `video_equal` says their frame stacks are.
|
||||
fn pixels_equal_with(a: &Document, b: &Document, video_equal: fn(&VideoData, &VideoData) -> bool) -> bool {
|
||||
fn walk(xs: &[Layer], ys: &[Layer], depth: usize, video_equal: fn(&VideoData, &VideoData) -> bool) -> bool {
|
||||
depth < 256
|
||||
&& xs.len() == ys.len()
|
||||
&& xs.iter().zip(ys).all(|(x, y)| {
|
||||
x.video.is_none()
|
||||
&& y.video.is_none()
|
||||
&& layer_pixels_equal(x, y)
|
||||
layer_pixels_equal(x, y)
|
||||
&& match (&x.video, &y.video) {
|
||||
(None, None) => true,
|
||||
(Some(vx), Some(vy)) => video_equal(vx, vy),
|
||||
_ => false,
|
||||
}
|
||||
&& match (x.children(), y.children()) {
|
||||
(Some(xc), Some(yc)) => walk(xc, yc, depth + 1),
|
||||
(Some(xc), Some(yc)) => walk(xc, yc, depth + 1, video_equal),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0)
|
||||
doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0, video_equal)
|
||||
}
|
||||
|
||||
/// Whether two video layers hold the same frames: the same settings and stack shape
|
||||
/// (`VideoData`'s own, shallow equality) and the same tile pointers in every frame, painted and
|
||||
/// original. Pointer compares only, like [`surfaces_equal`].
|
||||
fn video_frames_equal(a: &VideoData, b: &VideoData) -> bool {
|
||||
let frames = |x: &[photocraft_raster::Surface], y: &[photocraft_raster::Surface]| x.len() == y.len() && x.iter().zip(y).all(|(f, g)| surfaces_equal(f, g));
|
||||
*a == *b
|
||||
&& frames(&a.frames, &b.frames)
|
||||
&& match (&a.original_frames, &b.original_frames) {
|
||||
(Some(x), Some(y)) => frames(x, y),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` differ at most in their selection (#6865): a selection step, which must
|
||||
/// not mark a clean document modified. Pixels compare by tile pointer (as in [`same_pixels`],
|
||||
/// plus every frame of a video layer, whose own equality is shallow); every other field compares
|
||||
/// structurally, where copy-on-write `Arc`s of `Eq` data short-circuit on pointer identity. The
|
||||
/// destructure is exhaustive, so a new `Document` field fails to compile until it is classified.
|
||||
pub fn selection_only_change(a: &Document, b: &Document) -> bool {
|
||||
let Document {
|
||||
id,
|
||||
name,
|
||||
size: _,
|
||||
resolution_dpi: _,
|
||||
mode: _,
|
||||
depth: _,
|
||||
icc_profile: _,
|
||||
layers,
|
||||
channels: _,
|
||||
guides,
|
||||
ruler_origin,
|
||||
selection: _,
|
||||
metadata,
|
||||
global_light: _,
|
||||
paths,
|
||||
work_path,
|
||||
clipping_path,
|
||||
quick_mask: _,
|
||||
quick_mask_unselected,
|
||||
patterns: _,
|
||||
color_table: _,
|
||||
duotone: _,
|
||||
layer_comps,
|
||||
last_applied_comp,
|
||||
last_document_state,
|
||||
measurement,
|
||||
notes,
|
||||
text_styles,
|
||||
slices,
|
||||
variables,
|
||||
timeline,
|
||||
file_encoding,
|
||||
} = a;
|
||||
// The ignored fields above are the pixel ones `pixels_equal_with` already compares.
|
||||
pixels_equal_with(a, b, video_frames_equal)
|
||||
&& *id == b.id
|
||||
&& *name == b.name
|
||||
&& *layers == b.layers
|
||||
&& *guides == b.guides
|
||||
&& *ruler_origin == b.ruler_origin
|
||||
&& *metadata == b.metadata
|
||||
&& *paths == b.paths
|
||||
&& *work_path == b.work_path
|
||||
&& *clipping_path == b.clipping_path
|
||||
&& *quick_mask_unselected == b.quick_mask_unselected
|
||||
&& *layer_comps == b.layer_comps
|
||||
&& *last_applied_comp == b.last_applied_comp
|
||||
&& *last_document_state == b.last_document_state
|
||||
&& *measurement == b.measurement
|
||||
&& *notes == b.notes
|
||||
&& *text_styles == b.text_styles
|
||||
&& *slices == b.slices
|
||||
&& *variables == b.variables
|
||||
&& *timeline == b.timeline
|
||||
&& *file_encoding == b.file_encoding
|
||||
}
|
||||
|
||||
/// Pixels a single history step can have changed between `a` and `b` (the documents on either
|
||||
@@ -1076,6 +1162,84 @@ fn distribute(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
/// Reorder one or multiple layers to a single destination in one undoable edit.
|
||||
///
|
||||
/// Explicit `layers` are ordered according to the source document, not caller ordering.
|
||||
/// A transient snapshot for the Layers panel's "Preview on canvas when reordering layers".
|
||||
/// Uses the same source ordering and insertion semantics as `move_to`, but never edits history.
|
||||
/// Invalid/self-referential hover targets return None (the committed image stays visible).
|
||||
pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option<Document> {
|
||||
let target = LayerId(p.get("target")?.as_u64()?);
|
||||
let position = p.get("position")?.as_str()?;
|
||||
if !matches!(position, "above" | "below" | "into") {
|
||||
return None;
|
||||
}
|
||||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ids = if let Some(ids) = p.get("layers") {
|
||||
ids.as_array()?.iter().map(|v| v.as_u64().map(LayerId)).collect::<Option<Vec<_>>>()?
|
||||
} else {
|
||||
vec![LayerId(p.get("layer")?.as_u64()?)]
|
||||
};
|
||||
if ids.is_empty() || doc.layer(target).is_none() {
|
||||
return None;
|
||||
}
|
||||
let moved_ids = top_level(doc, &ids);
|
||||
if moved_ids.is_empty() || moved_ids.iter().any(|id| doc.layer(*id).is_none()) {
|
||||
return None;
|
||||
}
|
||||
if moved_ids.len() == 1 && moved_ids[0] == target && !copy {
|
||||
return None;
|
||||
}
|
||||
let target_path = doc.path_of(target)?;
|
||||
for id in &moved_ids {
|
||||
let path = doc.path_of(*id)?;
|
||||
if target_path.starts_with(&path) && !(copy && *id == target && position != "into") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if position == "into" && !doc.layer(target)?.is_group() {
|
||||
return None;
|
||||
}
|
||||
// The very structural move the drop commits, so the preview shows the same clip groups,
|
||||
// Background handling and group-depth refusals; a drop `move_to` would refuse has no preview.
|
||||
let mut preview = doc.clone();
|
||||
apply_move(&mut preview, &moved_ids, target, position, copy, add_copy).ok()?;
|
||||
Some(preview)
|
||||
}
|
||||
|
||||
/// The document-only part of `move_to`, shared with [`preview_move`] so the Layers panel's hover
|
||||
/// preview cannot drift from what the drop commits. Moves (or, with `copy`, duplicates) the
|
||||
/// top-level `moved_ids` to `pos` (`above`, `below` or `into`) `target`, then applies the
|
||||
/// group-depth cap, the Background-stays-at-the-bottom rule and the clip-group rules
|
||||
/// (`clip_group::settle`). Returns the ids of the new copies (empty for a move). On `Err` `doc` is
|
||||
/// half edited: run it inside `Session::edit` or on a scratch copy.
|
||||
fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: &str, copy: bool, add_copy: bool) -> Result<Vec<LayerId>> {
|
||||
const CMD: &str = "layer.moveTo";
|
||||
let background = crate::background_cmds::background_id(doc);
|
||||
let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, moved_ids) };
|
||||
let mut moved = Vec::with_capacity(moved_ids.len());
|
||||
for &id in moved_ids {
|
||||
moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
|
||||
}
|
||||
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
|
||||
// Look up the destination AFTER removing every source: its indices may have shifted.
|
||||
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
|
||||
if pos == "into" {
|
||||
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
|
||||
children.extend(moved);
|
||||
} else {
|
||||
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
|
||||
let siblings = if parent.is_empty() {
|
||||
&mut doc.layers
|
||||
} else {
|
||||
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
|
||||
};
|
||||
let at = if pos == "below" { idx } else { idx + 1 };
|
||||
siblings.splice(at..at, moved);
|
||||
}
|
||||
check_group_depth(doc, "Reorder Layer")?;
|
||||
crate::background_cmds::keep_background_at_bottom(background, doc)?;
|
||||
crate::clip_group::settle(doc, if copy { &copies } else { moved_ids }, &before, false);
|
||||
Ok(copies)
|
||||
}
|
||||
|
||||
/// Selected descendants of a moved group remain inside their parent; only top-level
|
||||
/// selected nodes are detached, and every reference is validated before editing.
|
||||
pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1133,32 +1297,9 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
let original_active = st.active_layer;
|
||||
let background = crate::background_cmds::background_id(doc);
|
||||
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
|
||||
let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| {
|
||||
let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, &moved_ids) };
|
||||
let mut moved = Vec::with_capacity(moved_ids.len());
|
||||
for &id in &moved_ids {
|
||||
moved.push(if copy { crate::commands::layer_copy(doc, id)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
|
||||
}
|
||||
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
|
||||
// Look up the destination AFTER removing every source: its indices may have shifted.
|
||||
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
|
||||
if pos == "into" {
|
||||
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
|
||||
children.extend(moved);
|
||||
} else {
|
||||
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
|
||||
let siblings = if parent.is_empty() {
|
||||
&mut doc.layers
|
||||
} else {
|
||||
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
|
||||
};
|
||||
let at = if pos == "below" { idx } else { idx + 1 };
|
||||
siblings.splice(at..at, moved);
|
||||
}
|
||||
check_group_depth(doc, "Reorder Layer")?;
|
||||
crate::background_cmds::keep_background_at_bottom(background, doc)?;
|
||||
crate::clip_group::settle(doc, if copy { &copies } else { &moved_ids }, &before, false);
|
||||
let copies = apply_move(doc, &moved_ids, target, pos, copy, add_copy)?;
|
||||
*active = if copy {
|
||||
copies.last().copied()
|
||||
} else if batch.is_some() {
|
||||
@@ -1281,7 +1422,8 @@ pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves
|
||||
/// into a new group placed where the top-most of them was. Bottom-to-top order is preserved.
|
||||
/// into a new, collapsed group placed where the top-most of them was. Bottom-to-top order is
|
||||
/// preserved.
|
||||
pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = match crate::commands::explicit_layer(s, p)? {
|
||||
Some(id) => vec![id],
|
||||
@@ -1295,7 +1437,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = top_level(doc, &ids);
|
||||
let top = *ids.last().ok_or(EngineError::NoLayer(LayerId(0)))?;
|
||||
let name = name.unwrap_or_else(|| doc.next_layer_name("Group"));
|
||||
let gid = doc.insert_above(Some(top), Layer::group(name, vec![]));
|
||||
// Photoshop creates the group collapsed, so the panel shows one row in place of the layers.
|
||||
let mut group = Layer::group(name, vec![]);
|
||||
if let LayerContent::Group(g) = &mut group.content {
|
||||
g.expanded = false;
|
||||
}
|
||||
let gid = doc.insert_above(Some(top), group);
|
||||
let mut children = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?);
|
||||
@@ -1406,13 +1553,14 @@ pub fn delete_selected(s: &mut Session) -> Result<Value> {
|
||||
pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result<Value> {
|
||||
let sel = selected(s);
|
||||
let old_active = s.active().and_then(|d| d.active_layer);
|
||||
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
|
||||
let (copies, active) = s.edit(label, |doc, active| {
|
||||
let mut copies = Vec::new();
|
||||
let mut new_active = None;
|
||||
for id in top_level(doc, &sel) {
|
||||
// Match the single-layer duplicate path: Background copies become
|
||||
// ordinary layers, while normal copies retain their original locks.
|
||||
let dup = crate::commands::layer_copy(doc, id)?;
|
||||
let dup = crate::commands::layer_copy(doc, id, add_copy)?;
|
||||
let nid = doc.insert_above(Some(id), dup);
|
||||
if !in_place {
|
||||
crate::artboard_cmds::place_copy(doc, nid)?;
|
||||
@@ -2417,6 +2565,7 @@ mod tests {
|
||||
let docu = doc(&s);
|
||||
let gl = docu.layer(g).unwrap();
|
||||
assert_eq!(gl.name, "Pair");
|
||||
assert!(matches!(&gl.content, LayerContent::Group(gr) if !gr.expanded), "created collapsed");
|
||||
assert_eq!(gl.children().unwrap().iter().map(|l| l.id).collect::<Vec<_>>(), vec![a, c]);
|
||||
// Root (bottom-to-top): Background, b, Pair (where c was), d.
|
||||
assert_eq!(docu.layers.iter().map(|l| l.id).skip(1).collect::<Vec<_>>(), vec![b, g, d]);
|
||||
@@ -2425,8 +2574,10 @@ mod tests {
|
||||
assert_eq!(doc(&s).layers.len(), 5);
|
||||
// ⌘G with several selected groups them too.
|
||||
select_all(&mut s, &[b, d]);
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_eq!(doc(&s).layers.len(), 4);
|
||||
// Both create the group collapsed, as Photoshop does (#5571).
|
||||
assert!(matches!(&doc(&s).layer(g).unwrap().content, LayerContent::Group(gr) if !gr.expanded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2850,6 +3001,7 @@ mod tests {
|
||||
assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err());
|
||||
}
|
||||
|
||||
/// `document.inspect` lists the selected layers and marks them in the layer list.
|
||||
#[test]
|
||||
fn inspect_reports_selection() {
|
||||
let mut s = session(8);
|
||||
@@ -2861,3 +3013,320 @@ mod tests {
|
||||
assert_eq!(v["layers"][0]["selected"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layers_panel_parity_tests {
|
||||
use super::*;
|
||||
|
||||
/// A 24×16 document with three layers, "Test 0" to "Test 2", above the Background; the last one is active.
|
||||
fn new_session() -> (Session, Vec<LayerId>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 24, "height": 16})).unwrap();
|
||||
let ids = (0..3).map(|n| LayerId(s.execute("layer.new.layer", json!({"name": format!("Test {n}")})).unwrap()["layer"].as_u64().unwrap())).collect();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// Set one Layers panel option in the preferences, as the Panel Options dialog does.
|
||||
fn option(s: &mut Session, key: &str, enabled: bool) {
|
||||
s.prefs.edit(|p| {
|
||||
let val = p.dialogs.entry("ui.layersPanelOptions".into()).or_insert_with(|| json!({}));
|
||||
if let Some(o) = val.as_object_mut() {
|
||||
o.insert(key.to_owned(), json!(enabled));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The drag preview equals the committed `layer.moveTo` result and never touches the document or its history.
|
||||
#[test]
|
||||
fn preview_matches_real_move_and_never_edits_document_or_history() {
|
||||
let (mut s, ids) = new_session();
|
||||
let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "above"});
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let undo_before = s.active().unwrap().history.entries().len();
|
||||
let candidate = preview_move(&before, ¶ms, true).unwrap();
|
||||
assert_eq!(*before, *s.active().unwrap().doc);
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), undo_before);
|
||||
s.execute("layer.moveTo", params).unwrap();
|
||||
assert_eq!(candidate, *s.active().unwrap().doc, "visual preview must match committed layer order");
|
||||
}
|
||||
|
||||
/// A previewed ⌥-drag copy names its layers like the committed copy, with and without the "copy" suffix option.
|
||||
#[test]
|
||||
fn preview_copy_and_no_copy_suffix_option_match_committed_layers() {
|
||||
let (mut s, ids) = new_session();
|
||||
option(&mut s, "addCopyToCopiedLayersAndGroups", false);
|
||||
let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "below", "copy": true});
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let candidate = preview_move(&before, ¶ms, false).unwrap();
|
||||
s.execute("layer.moveTo", params).unwrap();
|
||||
let actual = &s.active().unwrap().doc;
|
||||
assert_eq!(candidate.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), actual.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>());
|
||||
assert_eq!(actual.layer_count(), before.layer_count() + 1);
|
||||
assert_eq!(actual.layers.iter().filter(|l| l.name == "Test 0").count(), 2);
|
||||
let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap();
|
||||
let duplicate = LayerId(d["layer"].as_u64().unwrap());
|
||||
assert_eq!(s.active().unwrap().doc.layer(duplicate).unwrap().name, "Test 1");
|
||||
option(&mut s, "addCopyToCopiedLayersAndGroups", true);
|
||||
let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy"));
|
||||
}
|
||||
|
||||
/// Drops the command refuses (or ignores) give no preview and leave the document alone; a multi-layer preview follows the committed stack order.
|
||||
#[test]
|
||||
fn preview_rejects_invalid_drop_unchanged() {
|
||||
let (mut s, ids) = new_session();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
// Dropping a layer on itself is a no-op for the command, the others are errors.
|
||||
for (p, command_rejects) in [
|
||||
(json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), false),
|
||||
(json!({"layer": ids[1].0, "target": 999999, "position": "below"}), true),
|
||||
(json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), true),
|
||||
(json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), true),
|
||||
] {
|
||||
assert!(preview_move(&before, &p, true).is_none(), "{p}");
|
||||
let result = s.execute("layer.moveTo", p.clone());
|
||||
if command_rejects {
|
||||
assert!(result.is_err(), "{p}");
|
||||
} else {
|
||||
assert_eq!(result.unwrap(), Value::Null, "{p}");
|
||||
}
|
||||
assert_eq!(*before, *s.active().unwrap().doc, "{p}");
|
||||
}
|
||||
// Multi-layer preview and commit follow the same stack order regardless of params order.
|
||||
let p = json!({"layers": [ids[2].0, ids[0].0], "target": ids[1].0, "position": "below"});
|
||||
let candidate = preview_move(&before, &p, true).unwrap();
|
||||
s.execute("layer.moveTo", p).unwrap();
|
||||
assert_eq!(candidate, *s.active().unwrap().doc);
|
||||
}
|
||||
|
||||
/// A 16×16 document holding `spec` (bottom to top, `*` = clipped) above its Background. Every
|
||||
/// layer is a translucent, differently placed rectangle, so the stack order and the clip
|
||||
/// groups both show in the composite.
|
||||
fn clip_stack(spec: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
for (n, name) in spec.split_whitespace().enumerate() {
|
||||
let id = LayerId(s.execute("layer.new.layer", json!({"name": name.trim_end_matches('*')})).unwrap()["layer"].as_u64().unwrap());
|
||||
let n = n as i32;
|
||||
let colour = [(n % 3) as f32 * 0.5, ((n + 1) % 3) as f32 * 0.5, ((n + 2) % 3) as f32 * 0.5, 0.6];
|
||||
s.edit("paint", |doc, _| {
|
||||
let layer = doc.layer_mut(id).unwrap();
|
||||
layer.clipped = name.ends_with('*');
|
||||
layer.surface_mut().unwrap().fill_rect(Rect::new(n * 2, 0, n * 2 + 10, 16), &colour);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Every layer of `doc` from the bottom, `*` marking the clipped ones.
|
||||
fn stack_text(doc: &Document) -> String {
|
||||
doc.layers.iter().map(|l| format!("{}{}", l.name, if l.clipped { "*" } else { "" })).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// The id of the layer called `name`, searching the whole layer tree.
|
||||
fn id_of(s: &Session, name: &str) -> u64 {
|
||||
s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0
|
||||
}
|
||||
|
||||
/// The preview of `params` and the committed `layer.moveTo` show the same stack (names, clip
|
||||
/// flags) and the same composite, and both are `want`.
|
||||
fn assert_preview_is_commit(mut s: Session, params: Value, want: &str) {
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let preview = preview_move(&before, ¶ms, true).expect("the drop has a preview");
|
||||
s.execute("layer.moveTo", params.clone()).unwrap();
|
||||
let committed = &s.active().unwrap().doc;
|
||||
assert_eq!(stack_text(&preview), want, "preview of {params}");
|
||||
assert_eq!(stack_text(committed), want, "commit of {params}");
|
||||
assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px, "composite of {params}");
|
||||
}
|
||||
|
||||
/// Previewed moves join and release clipping groups exactly as the committed reorder does, copies included.
|
||||
#[test]
|
||||
fn preview_follows_clip_groups_like_the_committed_reorder() {
|
||||
// The fixtures of `clip_group::reorder_joins_and_leaves_groups`.
|
||||
for (spec, layers, target, pos, want) in [
|
||||
// A layer dropped into a clip group joins it.
|
||||
("B C1* C2* U", vec!["U"], "C1", "above", "Background B C1* U* C2*"),
|
||||
("B C1* C2* U V", vec!["U", "V"], "C1", "above", "Background B C1* U* V* C2*"),
|
||||
// A clipped layer dropped off its group is released.
|
||||
("B C1* C2* U", vec!["C1"], "U", "above", "Background B C2* U C1"),
|
||||
// A base moved away releases the layers clipped to it.
|
||||
("B C1* C2* U", vec!["B"], "U", "above", "Background C1 C2 U B"),
|
||||
// A base moved up into its own group joins it, and the layers it left are released.
|
||||
("B C1* C2*", vec!["B"], "C1", "above", "Background C1 B* C2"),
|
||||
("X B C1* C2* U", vec!["B", "C1"], "U", "above", "Background X C2 U B C1*"),
|
||||
] {
|
||||
let s = clip_stack(spec);
|
||||
let ids: Vec<u64> = layers.iter().map(|n| id_of(&s, n)).collect();
|
||||
let t = id_of(&s, target);
|
||||
assert_preview_is_commit(s, json!({"layers": ids, "target": t, "position": pos}), want);
|
||||
}
|
||||
// An option-drag copy joins the clip group it lands in too.
|
||||
let s = clip_stack("B C1* C2* U");
|
||||
let (u, c1) = (id_of(&s, "U"), id_of(&s, "C1"));
|
||||
assert_preview_is_commit(s, json!({"layer": u, "target": c1, "position": "above", "copy": true}), "Background B C1* U copy* C2* U");
|
||||
}
|
||||
|
||||
/// Copying the locked Background in a preview gives the same unlocked copy, joining the clip group it lands in, as the command.
|
||||
#[test]
|
||||
fn preview_copies_the_background_like_the_committed_copy() {
|
||||
let mut s = clip_stack("A B*");
|
||||
let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A"));
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
assert_ne!(before.layer(bg).unwrap().locks, Default::default(), "the Background is locked");
|
||||
let p = json!({"layer": bg.0, "target": a, "position": "above", "copy": true});
|
||||
let preview = preview_move(&before, &p, true).unwrap();
|
||||
s.execute("layer.moveTo", p).unwrap();
|
||||
let committed = &s.active().unwrap().doc;
|
||||
// The copy is an ordinary, unlocked layer that joins the clip group it lands in.
|
||||
assert_eq!(stack_text(committed), "Background A Background copy* B*");
|
||||
assert_eq!(stack_text(&preview), stack_text(committed));
|
||||
for doc in [&preview, committed] {
|
||||
assert_eq!(doc.layers.get(2).unwrap().locks, Default::default());
|
||||
assert!(doc.layers.first().unwrap().locks != Default::default());
|
||||
}
|
||||
assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px);
|
||||
}
|
||||
|
||||
/// A drop that would move the Background off the bottom has no preview, as the command refuses it.
|
||||
#[test]
|
||||
fn preview_refuses_what_would_leave_the_background_off_the_bottom() {
|
||||
let mut s = clip_stack("A B");
|
||||
let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A"));
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
for p in [
|
||||
json!({"layer": a, "target": bg.0, "position": "below"}),
|
||||
json!({"layer": bg.0, "target": bg.0, "position": "below", "copy": true}),
|
||||
json!({"layer": a, "target": bg.0, "position": "below", "copy": true}),
|
||||
] {
|
||||
assert!(preview_move(&before, &p, true).is_none(), "{p}");
|
||||
assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(*before, *s.active().unwrap().doc, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI runs commands through `start`; Expand New Effects applies there as well as to `execute`.
|
||||
#[test]
|
||||
fn expand_new_effects_applies_to_ui_start_dispatch_too() {
|
||||
let (mut s, ids) = new_session();
|
||||
option(&mut s, "expandNewEffects", false);
|
||||
let target = ids[0];
|
||||
let started = s.start("layer.layerStyle.dropShadow", json!({"layer": target.0})).unwrap();
|
||||
assert!(matches!(started, crate::jobs::Started::Done(_)));
|
||||
assert!(s.active().unwrap().fx_collapsed.contains(&target));
|
||||
}
|
||||
|
||||
/// New Solid Color and Gradient fill layers get the default mask only while Use Default Masks on Fill Layers is on.
|
||||
#[test]
|
||||
fn fill_layers_respect_use_default_masks_option() {
|
||||
let (mut s, _) = new_session();
|
||||
option(&mut s, "useDefaultMasksOnFillLayers", false);
|
||||
let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap();
|
||||
let id = LayerId(new["layer"].as_u64().unwrap());
|
||||
assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none());
|
||||
option(&mut s, "useDefaultMasksOnFillLayers", true);
|
||||
let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap();
|
||||
let id = LayerId(new["layer"].as_u64().unwrap());
|
||||
assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some());
|
||||
}
|
||||
|
||||
/// With the option off only the default reveal-all mask goes away: an active selection still
|
||||
/// becomes the layer mask and an explicit path still becomes the vector mask (path first).
|
||||
#[test]
|
||||
fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() {
|
||||
const KINDS: [(&str, &str); 3] = [
|
||||
("layer.newFillLayer.solidColor", r##"{"color": "#112233"}"##),
|
||||
("layer.newFillLayer.gradient", "{}"),
|
||||
("layer.newFillLayer.pattern", r#"{"pattern": "Checkerboard"}"#),
|
||||
];
|
||||
let path = json!({"subpaths": [{"closed": true, "knots": [[2, 2], [10, 2], [10, 10], [2, 10]]}]});
|
||||
let (mut s, _) = new_session();
|
||||
option(&mut s, "useDefaultMasksOnFillLayers", false);
|
||||
// Creates the fill layer; returns its raster mask and whether it has a vector mask.
|
||||
let create = |s: &mut Session, cmd: &str, params: &str, path: Option<&Value>| {
|
||||
let mut p: Value = serde_json::from_str(params).unwrap();
|
||||
if let Some(path) = path {
|
||||
p["path"] = path.clone();
|
||||
}
|
||||
let id = LayerId(s.execute(cmd, p).unwrap()["layer"].as_u64().unwrap());
|
||||
let l = s.active().unwrap().doc.layer(id).unwrap();
|
||||
(l.mask.clone(), l.vector_mask.is_some())
|
||||
};
|
||||
|
||||
for (cmd, params) in KINDS {
|
||||
let (mask, vector) = create(&mut s, cmd, params, None);
|
||||
assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all");
|
||||
}
|
||||
|
||||
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
|
||||
for (cmd, params) in KINDS {
|
||||
let (mask, vector) = create(&mut s, cmd, params, None);
|
||||
let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask"));
|
||||
assert!(!vector, "{cmd}");
|
||||
assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals");
|
||||
assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides");
|
||||
}
|
||||
|
||||
// An explicit path wins over the selection, as with the option on.
|
||||
for selected in [true, false] {
|
||||
if !selected {
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
}
|
||||
for (cmd, params) in KINDS {
|
||||
let (mask, vector) = create(&mut s, cmd, params, Some(&path));
|
||||
assert!(vector, "{cmd}: the explicit path becomes the vector mask (selection: {selected})");
|
||||
assert!(mask.is_none(), "{cmd}: no raster mask next to the vector mask (selection: {selected})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether new effects start expanded is view state: it adds no history step, and editing an existing effect keeps its disclosure.
|
||||
#[test]
|
||||
fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() {
|
||||
let (mut s, ids) = new_session();
|
||||
option(&mut s, "expandNewEffects", false);
|
||||
let target = ids[0];
|
||||
let history = s.active().unwrap().history.entries().len();
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 2})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), history + 1);
|
||||
assert!(s.active().unwrap().fx_collapsed.contains(&target));
|
||||
option(&mut s, "expandNewEffects", true);
|
||||
let other = ids[1];
|
||||
s.execute("layer.layerStyle.stroke", json!({"layer": other.0, "size": 2})).unwrap();
|
||||
assert!(!s.active().unwrap().fx_collapsed.contains(&other));
|
||||
// Editing the existing effect must not unexpectedly change its disclosure.
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 5})).unwrap();
|
||||
assert!(s.active().unwrap().fx_collapsed.contains(&target));
|
||||
}
|
||||
|
||||
/// The disclosure follows the layer the command changes, however the command names it: a
|
||||
/// layer given by name is not the active one.
|
||||
#[test]
|
||||
fn expand_new_effects_follows_a_layer_named_by_its_name() {
|
||||
let (mut s, ids) = new_session();
|
||||
option(&mut s, "expandNewEffects", false);
|
||||
let (named, active) = (ids[0], ids[2]);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(active));
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"layer": "Test 0", "size": 2})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(named).is_some_and(|l| !l.effects.items.is_empty()));
|
||||
assert!(s.active().unwrap().fx_collapsed.contains(&named), "the named layer's new effects start collapsed");
|
||||
assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed");
|
||||
}
|
||||
|
||||
/// Transfer Effects changes the layer it moves the effects to, so that is the layer whose
|
||||
/// disclosure follows the option, not the active layer or the one the effects came from.
|
||||
#[test]
|
||||
fn expand_new_effects_follows_the_transfer_destination() {
|
||||
let (mut s, ids) = new_session();
|
||||
let (from, to, active) = (ids[0], ids[1], ids[2]);
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"layer": from.0, "size": 2})).unwrap();
|
||||
option(&mut s, "expandNewEffects", false);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(active));
|
||||
s.execute("layer.layerStyle.transferEffects", json!({"from": from.0, "to": to.0, "copy": true})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(to).is_some_and(|l| !l.effects.items.is_empty()));
|
||||
assert!(s.active().unwrap().fx_collapsed.contains(&to), "the destination's new effects start collapsed");
|
||||
assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed");
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ mod tests {
|
||||
s.execute("layer.select", json!({"layer": c.0, "mode": "add"})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let group = s.active().unwrap().doc.layers.iter().find(|l| l.is_group()).unwrap().id;
|
||||
s.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true})).unwrap();
|
||||
let d = id(&mut s, "layer.new.layer", json!({"name": "D"}));
|
||||
(s, [bg, a, group, b, c, d])
|
||||
}
|
||||
|
||||
@@ -86,19 +86,19 @@ pub fn effect_defaults(kind: &str) -> Value {
|
||||
json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||||
}
|
||||
"outerGlow" => {
|
||||
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||||
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||||
}
|
||||
"innerGlow" => {
|
||||
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||||
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "source": "edge", "choke": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||||
}
|
||||
"stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}),
|
||||
"stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}),
|
||||
"colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}),
|
||||
"gradientOverlay" => {
|
||||
json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100})
|
||||
}
|
||||
"patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}),
|
||||
"bevelEmboss" => {
|
||||
json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
|
||||
json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "glossAntiAlias": false, "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
|
||||
}
|
||||
"satin" => {
|
||||
json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true})
|
||||
@@ -510,6 +510,7 @@ fn fresh_effect(kind: &str) -> Option<Effect> {
|
||||
size: 1.0,
|
||||
position: StrokePosition::Inside,
|
||||
paint: FxPaint::Color(Color::BLACK),
|
||||
overprint: false,
|
||||
}),
|
||||
"colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) },
|
||||
"gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false },
|
||||
@@ -544,6 +545,7 @@ fn fresh_effect(kind: &str) -> Option<Effect> {
|
||||
altitude: 30.0,
|
||||
use_global_light: true,
|
||||
gloss_contour: Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: FxCommon::new(BlendMode::Screen, 0.75),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
@@ -693,6 +695,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) {
|
||||
_ => StrokePosition::Outside,
|
||||
};
|
||||
}
|
||||
set_bool(p, "overprint", &mut s.overprint);
|
||||
if p.get("from").is_some() {
|
||||
s.paint = FxPaint::Gradient(gradient(p));
|
||||
} else if let Some(c) = color_of(p, "color") {
|
||||
@@ -792,6 +795,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) {
|
||||
bv.use_global_light = x;
|
||||
}
|
||||
set_contour(p, "glossContour", &mut bv.gloss_contour);
|
||||
set_bool(p, "glossAntiAlias", &mut bv.gloss_anti_alias);
|
||||
// The Contour element: `contour` turns it on/off; its curve, Range and
|
||||
// Anti-aliased edit the element in place, so an imported curve survives.
|
||||
match b_of(p, "contour") {
|
||||
@@ -1002,17 +1006,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
style_cmd!(
|
||||
"outerGlow",
|
||||
"Outer Glow…",
|
||||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||||
),
|
||||
style_cmd!(
|
||||
"innerGlow",
|
||||
"Inner Glow…",
|
||||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||||
),
|
||||
style_cmd!(
|
||||
"stroke",
|
||||
"Stroke…",
|
||||
r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"##
|
||||
r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"overprint":bool,"add":bool}"##
|
||||
),
|
||||
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
|
||||
style_cmd!(
|
||||
@@ -1028,7 +1032,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
style_cmd!(
|
||||
"bevelEmboss",
|
||||
"Bevel & Emboss…",
|
||||
r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##
|
||||
r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"glossAntiAlias":bool,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##
|
||||
),
|
||||
style_cmd!(
|
||||
"satin",
|
||||
@@ -1144,6 +1148,48 @@ mod tests {
|
||||
assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true));
|
||||
}
|
||||
|
||||
/// #6698 review: a glow's Range runs 1..100 %, as its slider and Photoshop's do. A 0 is
|
||||
/// held at the 1 % floor, and 100 is kept.
|
||||
#[test]
|
||||
fn glow_range_is_held_to_one_through_one_hundred() {
|
||||
for kind in ["outerGlow", "innerGlow"] {
|
||||
for (sent, kept) in [(0.0, 0.01), (-5.0, 0.01), (1.0, 0.01), (50.0, 0.5), (100.0, 1.0), (250.0, 1.0)] {
|
||||
let mut s = session();
|
||||
s.execute(&format!("layer.layerStyle.{kind}"), json!({"range": sent})).unwrap();
|
||||
let range = match &effects(&s)[0] {
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.range,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!((range - kept).abs() < 1e-6, "{kind} range {sent} kept as {range}, not {kept}");
|
||||
}
|
||||
}
|
||||
let doc = |id: &str| specs().into_iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default();
|
||||
assert!(doc("layer.layerStyle.outerGlow").contains(r#""range":1..100=50"#));
|
||||
assert!(doc("layer.layerStyle.innerGlow").contains(r#""range":1..100=50"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_gloss_anti_alias_is_a_param() {
|
||||
let mut s = session();
|
||||
s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap();
|
||||
let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() };
|
||||
assert!(b.gloss_anti_alias);
|
||||
s.execute("layer.layerStyle.bevelEmboss", json!({"glossAntiAlias": false})).unwrap();
|
||||
let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() };
|
||||
assert!(!b.gloss_anti_alias);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_overprint_is_a_param() {
|
||||
let mut s = session();
|
||||
s.execute("layer.layerStyle.stroke", json!({"size": 4, "overprint": true})).unwrap();
|
||||
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
|
||||
assert!(st.overprint);
|
||||
s.execute("layer.layerStyle.stroke", json!({"overprint": false})).unwrap();
|
||||
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
|
||||
assert!(!st.overprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_style_command_adds_its_effect() {
|
||||
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] {
|
||||
@@ -1356,6 +1402,7 @@ mod tests {
|
||||
size: 7.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }),
|
||||
overprint: false,
|
||||
});
|
||||
let r = s
|
||||
.execute(
|
||||
@@ -1392,6 +1439,7 @@ mod tests {
|
||||
size: 3.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::BLACK),
|
||||
overprint: false,
|
||||
});
|
||||
for p in [
|
||||
json!({}),
|
||||
|
||||
@@ -211,6 +211,8 @@ pub struct DocState {
|
||||
/// Increments on every change; UIs re-render when it moves.
|
||||
pub revision: u64,
|
||||
pub saved_revision: u64,
|
||||
/// Native save cache shared with this document's workers, released with the editing state.
|
||||
pub pcraft_cache: photocraft_io::PcraftExportCache,
|
||||
/// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those.
|
||||
pub view_revision: u64,
|
||||
/// Area changed by the latest revision (None = assume everything changed).
|
||||
@@ -275,6 +277,7 @@ impl DocState {
|
||||
source_read_only: false,
|
||||
revision: 1,
|
||||
saved_revision: 1,
|
||||
pcraft_cache: Default::default(),
|
||||
view_revision: 0,
|
||||
last_damage: None,
|
||||
coalesce: None,
|
||||
@@ -304,6 +307,21 @@ impl DocState {
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.revision != self.saved_revision
|
||||
}
|
||||
/// Whether the step from `before` to the current document leaves a clean document clean:
|
||||
/// it changed nothing but the selection (#6865). Making, changing or dropping a selection
|
||||
/// (marquee, lasso, Magic Wand, Quick / Object Selection, Select › …) is a history step but
|
||||
/// not an edit, so it neither marks the file modified nor makes closing ask to save.
|
||||
pub(crate) fn keeps_clean(&self, before: &Document) -> bool {
|
||||
self.saved_revision == self.revision && layer_multi_cmds::selection_only_change(before, &self.doc)
|
||||
}
|
||||
/// Move to the next revision; `keep_clean` (from [`Self::keeps_clean`]) moves the saved
|
||||
/// revision with it.
|
||||
pub(crate) fn bump_revision(&mut self, keep_clean: bool) {
|
||||
self.revision += 1;
|
||||
if keep_clean {
|
||||
self.saved_revision = self.revision;
|
||||
}
|
||||
}
|
||||
/// The targeted layers, as history stores them with each state.
|
||||
fn layer_target(&self) -> LayerTarget {
|
||||
LayerTarget { active: self.active_layer, selected: self.selected_layers.clone() }
|
||||
@@ -857,6 +875,7 @@ impl Session {
|
||||
// duplicate or a paste), and everything when that can't be bounded. A command may still
|
||||
// report its own, tighter damage after this.
|
||||
let damage = layer_multi_cmds::step_damage(&before, &st.doc);
|
||||
let keep_clean = st.keeps_clean(&before);
|
||||
let key = self.coalesce_request.clone();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let layers = st.layer_target();
|
||||
@@ -868,7 +887,7 @@ impl Session {
|
||||
st.history.set_current_layers(layers);
|
||||
}
|
||||
st.coalesce = key;
|
||||
st.revision += 1;
|
||||
st.bump_revision(keep_clean);
|
||||
st.last_damage = damage;
|
||||
self.samplers_removed += samplers_removed;
|
||||
if damage.is_none_or(|d| !d.is_empty()) {
|
||||
@@ -899,6 +918,31 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put back a layer selection (active layer, selected layers, anchor) that a step moved, such
|
||||
/// as the undo ending an empty Type Mask, which restores the selection from before it. `None`
|
||||
/// is no layer selected. Selected layers that are gone are dropped; an active layer that is
|
||||
/// gone is an error and changes nothing. Like [`Self::select_layer`], not an edit.
|
||||
pub fn restore_layer_selection(&mut self, active: Option<LayerId>, selected: &[LayerId], anchor: Option<LayerId>) -> Result<()> {
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
if let Some(id) = active
|
||||
&& st.doc.layer(id).is_none()
|
||||
{
|
||||
return Err(EngineError::NoLayer(id));
|
||||
}
|
||||
st.active_layer = active;
|
||||
st.selected_layers = selected.to_vec();
|
||||
st.layer_anchor = anchor.filter(|_| active.is_some());
|
||||
fix_selection(st);
|
||||
channel_cmds::fix_view(st);
|
||||
let clean = st.saved_revision == st.revision;
|
||||
st.revision += 1;
|
||||
if clean {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-renders the pixels of type layers whose font arrived after they were drawn (the web
|
||||
/// build fetches served fonts on demand, `photocraft_text::served`). The layers' model is
|
||||
/// unchanged, so this is no history step and leaves a clean document clean. Unknown documents
|
||||
@@ -969,9 +1013,10 @@ impl Session {
|
||||
// Pixels this step can have touched, so the canvas recomposites only that
|
||||
// (it recomposited everything before).
|
||||
let damage = layer_multi_cmds::step_damage(&d, &st.doc);
|
||||
st.doc = d;
|
||||
let before = std::mem::replace(&mut st.doc, d);
|
||||
let keep_clean = st.keeps_clean(&before);
|
||||
restore_target(st, layers);
|
||||
st.revision += 1;
|
||||
st.bump_revision(keep_clean);
|
||||
st.last_damage = damage;
|
||||
true
|
||||
}
|
||||
@@ -991,9 +1036,10 @@ impl Session {
|
||||
Arc::make_mut(&mut d).name.clone_from(&st.doc.name);
|
||||
}
|
||||
let damage = layer_multi_cmds::step_damage(&st.doc, &d);
|
||||
st.doc = d;
|
||||
let before = std::mem::replace(&mut st.doc, d);
|
||||
let keep_clean = st.keeps_clean(&before);
|
||||
restore_target(st, layers);
|
||||
st.revision += 1;
|
||||
st.bump_revision(keep_clean);
|
||||
st.last_damage = damage;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -396,6 +396,10 @@ fn color_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Bitmap's Output resolution range (ppi): what a new document may have.
|
||||
const BITMAP_RESOLUTION: std::ops::RangeInclusive<f64> = 1.0..=crate::document_preset_cmds::MAX_RESOLUTION;
|
||||
|
||||
/// Bytes per pixel of the Bitmap conversion's full-canvas working buffers at their peak (see
|
||||
/// `bitmap`), on top of the document itself.
|
||||
const BITMAP_WORK_BYTES_PER_PX: u64 = 40;
|
||||
|
||||
/// Bitmap's Custom Pattern: a pattern's threshold map (see
|
||||
/// [`quantize::custom_pattern_thresholds`]) with its width and height.
|
||||
fn custom_pattern_map(s: &Session, cmd: &str, p: &Value) -> Result<(Vec<f32>, usize, usize)> {
|
||||
@@ -427,11 +431,23 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nw, nh) = if (output - input).abs() < 1e-6 {
|
||||
(ow, oh)
|
||||
} else {
|
||||
let side = |v: u32| (f64::from(v) * output / input).round().clamp(1.0, 300_000.0) as u32;
|
||||
(side(ow), side(oh))
|
||||
// Keeping the print size can ask for more pixels than a document may have: refuse that
|
||||
// rather than clamp, which would change the print size.
|
||||
let max = crate::document_preset_cmds::MAX_DIMENSION;
|
||||
let side = |v: u32| {
|
||||
let n = (f64::from(v) * output / input).round().max(1.0);
|
||||
if n > f64::from(max) {
|
||||
return Err(bad(CMD, format!("{n} px across at {output} pixels/inch is more than {max} px; choose a lower Output resolution")));
|
||||
}
|
||||
Ok(n as u32)
|
||||
};
|
||||
(side(ow)?, side(oh)?)
|
||||
};
|
||||
if (nw, nh) != (ow, oh) {
|
||||
crate::image_cmds::check_resample_budget(CMD, &d.doc, nw, nh, ", or a lower Output resolution")?;
|
||||
// Besides the resampled document, the conversion holds an RGBA f32 composite (16 B/px; 32
|
||||
// while it is laid over white), the gray plane (4) and the RGBA f32 output (16) at the new
|
||||
// size: budget them all before allocating, not just the resample.
|
||||
crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?;
|
||||
}
|
||||
let dpi = output as f32;
|
||||
let mut custom = None;
|
||||
|
||||
@@ -470,6 +470,37 @@ fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() {
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height, doc(&s).resolution_dpi), (1, 1, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() {
|
||||
// 100 x 100 px at 1 ppi to 200 ppi is 20000 x 20000 px: 400 MP. Resampling an 8/16-bit gray
|
||||
// document to it fits the resample budget (under 2 GB), but the conversion's f32 working
|
||||
// buffers at that size (~40 B/px, 16 GB) don't: it is refused before anything is allocated.
|
||||
for depth in [8, 16] {
|
||||
let mut s = ramp(100, 100, depth, 1.0);
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!(crate::image_cmds::check_resample_budget("t", d, 20_000, 20_000, "").is_ok(), "{depth}");
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 200})).unwrap_err().to_string();
|
||||
assert!(e.contains("megapixels") && e.contains("Output resolution"), "{depth}: {e}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before, "{depth}");
|
||||
let d = doc(&s);
|
||||
assert_eq!((d.mode, d.size.width, d.resolution_dpi), (ColorMode::Grayscale, 100, 1.0), "{depth}");
|
||||
// A side past the 300,000 px document limit is refused, not clamped (that would change
|
||||
// the print size): 20000 px at 72 ppi is 333,333 px at 1200 ppi.
|
||||
let mut wide = ramp(20_000, 1, depth, 72.0);
|
||||
let e = wide.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 1200})).unwrap_err().to_string();
|
||||
assert!(e.contains("300000 px"), "{depth}: {e}");
|
||||
assert_eq!((doc(&wide).mode, doc(&wide).size.width), (ColorMode::Grayscale, 20_000), "{depth}");
|
||||
// 18000 px to 300,000 px exactly is allowed.
|
||||
let mut edge = ramp(18_000, 1, depth, 72.0);
|
||||
edge.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 1200})).unwrap();
|
||||
assert_eq!(doc(&edge).size.width, 300_000, "{depth}");
|
||||
// A size that fits is still converted.
|
||||
s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 3})).unwrap();
|
||||
assert_eq!((doc(&s).size.width, doc(&s).mode), (300, ColorMode::Bitmap), "{depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duotone_inks_and_display() {
|
||||
let mut s = session(16, 8, 8, "gray");
|
||||
|
||||
@@ -317,23 +317,32 @@ pub(crate) fn placement(cmd: &str, p: &Value) -> Result<(f32, f32, bool, (f32, f
|
||||
|
||||
/// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks,
|
||||
/// above `active`. The Pattern Fill dialog previews it on a copy of the document.
|
||||
pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option<photocraft_doc::LayerId>, pat: &Pattern, p: &Value) -> Result<photocraft_doc::LayerId> {
|
||||
pub(crate) fn insert_fill_layer(
|
||||
doc: &mut Document,
|
||||
active: &mut Option<photocraft_doc::LayerId>,
|
||||
pat: &Pattern,
|
||||
p: &Value,
|
||||
default_mask: bool,
|
||||
) -> Result<photocraft_doc::LayerId> {
|
||||
let (scale, angle, link, phase) = placement("layer.newFillLayer.pattern", p)?;
|
||||
ensure_in_doc(doc, pat);
|
||||
let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase };
|
||||
let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill));
|
||||
crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?;
|
||||
crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?;
|
||||
let id = doc.insert_above(*active, l);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// `layer.newFillLayer.pattern`: a Pattern Fill layer above the active one, masked as the Layers panel's Use Default Masks on Fill Layers option says.
|
||||
fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?;
|
||||
let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p))?;
|
||||
let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
|
||||
let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p, default_mask))?;
|
||||
Ok(json!({"layer": id.0, "pattern": pat.id}))
|
||||
}
|
||||
|
||||
/// `brush.texturePattern`: use a pattern as the brush texture, converted to grey as the luminance of its composite over white.
|
||||
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "brush.texturePattern", p)?;
|
||||
let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten();
|
||||
|
||||
@@ -124,13 +124,15 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result<Document> {
|
||||
CREATE => {
|
||||
let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?;
|
||||
let mut active = st.active_layer;
|
||||
crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p)?;
|
||||
let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
|
||||
crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?;
|
||||
}
|
||||
other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }),
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Register `layer.fill.pattern.set` (the Pattern Fill dialog's command), journaled like any other edit.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: SET,
|
||||
@@ -216,6 +218,40 @@ mod tests {
|
||||
assert!(preview(&s, "edit.fill", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// At 8, 16 and 32 bits the Pattern Fill preview and the committed layer carry the default mask only while the option is on, and creating one is one undoable step.
|
||||
#[test]
|
||||
fn create_preview_and_commit_honor_default_masks_option() {
|
||||
for depth in [8, 16, 32] {
|
||||
for default_mask in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap();
|
||||
s.edit_prefs(|prefs| {
|
||||
prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask}));
|
||||
});
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let count = s.active().unwrap().history.entries().len();
|
||||
let p = json!({"pattern": "Grid", "scale": 50});
|
||||
let shown = preview(&s, CREATE, &p).unwrap();
|
||||
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), count);
|
||||
let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap();
|
||||
assert_eq!(previewed.mask.is_some(), default_mask);
|
||||
if let Some(mask) = &previewed.mask {
|
||||
assert_eq!(mask.surface.format(), photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, shown.depth, false));
|
||||
}
|
||||
let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap());
|
||||
let committed = s.active().unwrap().doc.layer(id).unwrap();
|
||||
assert_eq!(committed.mask, previewed.mask);
|
||||
assert_eq!(committed.content, previewed.content);
|
||||
s.undo();
|
||||
assert_eq!(*s.active().unwrap().doc, *before);
|
||||
s.redo();
|
||||
assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Malformed or out-of-range params, and missing layers or patterns, are errors in both the command and its preview, never panics.
|
||||
#[test]
|
||||
fn bad_params_and_targets_are_errors_not_panics() {
|
||||
let (mut s, id) = session(8);
|
||||
|
||||
@@ -371,6 +371,10 @@ mod tests {
|
||||
#[test]
|
||||
fn perspective_crop_rejects_bad_quads() {
|
||||
let mut s = skewed_ramp(SampleType::U8, &[[0.0, 0.0], [160.0, 0.0], [160.0, 120.0], [0.0, 120.0]], 160.0, 120.0);
|
||||
// A rejected crop leaves the Color Sampler points and the removed count alone.
|
||||
let samplers = vec![[5.0, 5.0], [150.0, 110.0]];
|
||||
s.active_mut().unwrap().color_samplers = samplers.clone();
|
||||
s.samplers_removed = 2;
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"corners": [[0, 0], [10, 0], [10, 10]]}),
|
||||
@@ -388,5 +392,7 @@ mod tests {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.size, Size::new(160, 120));
|
||||
assert_eq!(s.active().unwrap().color_samplers, samplers);
|
||||
assert_eq!(s.take_samplers_removed(), 2);
|
||||
}
|
||||
}
|
||||
@@ -239,16 +239,21 @@ pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], sel
|
||||
let fmt = s.format();
|
||||
// Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept).
|
||||
let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32);
|
||||
let solid = opaque.then(|| {
|
||||
let mut enc = [0.0f32; 8];
|
||||
let n = photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc);
|
||||
s.solid_tile(enc.get(..n).unwrap_or(&[]))
|
||||
});
|
||||
let mut enc = [0.0f32; 8];
|
||||
let enc_len = opaque.then(|| photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc));
|
||||
let solid = enc_len.map(|n| s.solid_tile(enc.get(..n).unwrap_or(&[])));
|
||||
let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone()));
|
||||
edit_tiles(s, area, selection, "filling pixels", whole, |k, px| {
|
||||
if k <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
if k >= 1.0
|
||||
&& let Some(m) = enc_len
|
||||
&& let Some(src) = enc.get(..px.len().min(m))
|
||||
{
|
||||
px.copy_from_slice(src);
|
||||
return true;
|
||||
}
|
||||
fill_pixel(&fmt, px, color, k, lock_transparency);
|
||||
true
|
||||
})
|
||||
@@ -294,9 +299,31 @@ pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&
|
||||
px.fill(0.0);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
s.prune();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Take the coverage a floating selection lifts out of `s` over `area`: each pixel loses its
|
||||
/// selection coverage k of alpha, down to none, so a selection made from the layer's own
|
||||
/// transparency leaves nothing behind (#7655), where [`try_clear_surface`] would leave a ghost.
|
||||
pub(crate) fn try_lift_coverage(s: &mut Surface, area: Rect, selection: &Surface) -> crate::Result<()> {
|
||||
let fmt = s.format();
|
||||
let n = fmt.channels();
|
||||
if !fmt.alpha {
|
||||
return Ok(());
|
||||
}
|
||||
let empty_default = s.default_bytes().iter().all(|&b| b == 0);
|
||||
let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop);
|
||||
edit_tiles(s, area, Some(selection), "lifting pixels", whole, |k, px| {
|
||||
if k <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let Some(a) = px.get_mut(n - 1) else { return false };
|
||||
*a = (*a - k).max(0.0);
|
||||
if *a <= 0.0 {
|
||||
px.fill(0.0);
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// A whole-tile shortcut of [`edit_tiles`].
|
||||
@@ -331,6 +358,7 @@ fn edit_tiles(
|
||||
coords.extend(area.tiles());
|
||||
// Untouched pixels read as the default where the surface has no tile.
|
||||
let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None };
|
||||
let dp = s.default_bytes();
|
||||
let surf = &*s;
|
||||
let edits = coords
|
||||
.into_par_iter()
|
||||
@@ -371,7 +399,11 @@ fn edit_tiles(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out.map(|t| (tc, Some(Arc::new(t)))))
|
||||
Ok(match out {
|
||||
Some(t) if bpp > 0 && t.bytes().chunks_exact(bpp).all(|p| p == dp) => current.map(|_| (tc, None)),
|
||||
Some(t) => Some((tc, Some(Arc::new(t)))),
|
||||
None => None,
|
||||
})
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
for (tc, tile) in edits.into_iter().flatten() {
|
||||
|
||||
@@ -514,11 +514,14 @@ impl Default for Export {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Performance::memory_usage_mb`]'s default.
|
||||
pub const DEFAULT_MEMORY_USAGE_MB: u32 = 8192;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Performance {
|
||||
/// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the
|
||||
/// oldest states are dropped beyond it).
|
||||
/// oldest states are dropped beyond it), and the result of one transform.
|
||||
pub memory_usage_mb: u32,
|
||||
/// Undo steps kept per document (History panel states).
|
||||
pub history_states: u32,
|
||||
@@ -573,9 +576,10 @@ impl Performance {
|
||||
}
|
||||
|
||||
impl Default for Performance {
|
||||
/// What a new install starts with, [`DEFAULT_MEMORY_USAGE_MB`] of Memory Usage among it.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
memory_usage_mb: 8192,
|
||||
memory_usage_mb: DEFAULT_MEMORY_USAGE_MB,
|
||||
history_states: 50,
|
||||
cache_levels: 4,
|
||||
cache_tile_size: 8192,
|
||||
@@ -1487,7 +1491,15 @@ pub fn normalize_shortcut(s: &str) -> Option<String> {
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Key names spelled with more than one capital (egui's `Key::name`): Title case would turn
|
||||
/// `PageUp` into `Pageup`, which no key matches, so a binding saved that way never fired (#6827).
|
||||
const MULTI_WORD_KEYS: [&str; 8] =
|
||||
["PageUp", "PageDown", "OpenBracket", "CloseBracket", "OpenCurlyBracket", "CloseCurlyBracket", "BrowserBack", "IntlBackslash"];
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
if let Some(k) = MULTI_WORD_KEYS.iter().find(|k| k.eq_ignore_ascii_case(s)) {
|
||||
return (*k).to_string();
|
||||
}
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
Some(f) if f.is_ascii_alphabetic() => {
|
||||
@@ -1519,7 +1531,8 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
|
||||
/// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key
|
||||
/// (the one its menu shows) and answers these as well, as in Photoshop's default set: the
|
||||
/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace
|
||||
/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In.
|
||||
/// (Shift+Delete on a Mac) for Fill; ⌘+ beside ⌘= for Zoom In; and off the Mac, Ctrl+F4 beside
|
||||
/// Ctrl+W for Close (Windows' close-document key; macOS keeps Ctrl+F4 for window focus).
|
||||
pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("edit.cut", "F2"),
|
||||
("edit.copy", "F3"),
|
||||
@@ -1527,6 +1540,8 @@ pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("edit.fill", "Shift+Backspace"),
|
||||
("select.inverse", "Shift+F7"),
|
||||
("view.zoomIn", "Cmd++"),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
("file.close", "Cmd+F4"),
|
||||
];
|
||||
|
||||
/// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]).
|
||||
@@ -1738,7 +1753,7 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Push engine-relevant preferences into live state: every document's history limit, the
|
||||
/// layer-effect cache budget and the text engine's missing-glyph protection.
|
||||
/// transform and layer-effect cache budgets and the text engine's missing-glyph protection.
|
||||
pub fn apply_prefs(&mut self) {
|
||||
let n = self.prefs().performance.history_states.max(1) as usize;
|
||||
let bytes = self.prefs().performance.history_budget_bytes();
|
||||
@@ -1749,6 +1764,7 @@ impl Session {
|
||||
st.trim_history();
|
||||
}
|
||||
photocraft_compose::set_effect_cache_budget(budget << 20);
|
||||
crate::transform_cmds::set_budget(u64::from(self.prefs().performance.memory_usage_mb) << 20);
|
||||
// Without waiting for the system font scan: this runs on the UI thread at launch.
|
||||
photocraft_text::set_shared_missing_glyph_protection(self.prefs().type_.missing_glyph_protection());
|
||||
crate::plugin_cmds::sync_prefs(self);
|
||||
|
||||
@@ -343,6 +343,11 @@ fn shortcuts_normalise_and_detect_conflicts() {
|
||||
assert_eq!(normalize_shortcut("Cmd++").as_deref(), Some("Cmd++"));
|
||||
assert_eq!(normalize_shortcut("Cmd+Shift"), None);
|
||||
assert_eq!(normalize_shortcut("Cmd+A+B"), None);
|
||||
// #6827: multi-word key names keep egui's spelling (Title case made `Pageup`, matching no key).
|
||||
assert_eq!(normalize_shortcut("shift+pageup").as_deref(), Some("Shift+PageUp"));
|
||||
assert_eq!(normalize_shortcut("Ctrl+Alt+PAGEDOWN").as_deref(), Some("Ctrl+Alt+PageDown"));
|
||||
assert_eq!(normalize_shortcut("Cmd+openbracket").as_deref(), Some("Cmd+OpenBracket"));
|
||||
assert_eq!(normalize_shortcut("home").as_deref(), Some("Home"));
|
||||
let c = conflicts([("a", "Cmd+J"), ("b", "cmd+j"), ("c", "Cmd+K")]);
|
||||
assert_eq!(c, vec![("Cmd+J".to_string(), vec!["a".to_string(), "b".to_string()])]);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A background job when started with `Session::start` (#210): reading and decoding the
|
||||
// file run on a worker, cancellable before each brush; the library changes on apply.
|
||||
let params = p.clone();
|
||||
let params_apply = p.clone();
|
||||
// Only the keys the apply step reads: `data` can be a ~1.3 GiB base64 string.
|
||||
let params_apply = json!({ "replace": p.get("replace"), "select": p.get("select") });
|
||||
crate::jobs::run(
|
||||
s,
|
||||
label,
|
||||
|
||||
@@ -775,14 +775,22 @@ fn a_damaged_preset_set_keeps_the_other_sets() {
|
||||
let mut s = Session::new();
|
||||
let settings = json!({"width": 1600, "height": 900, "resolution": 144});
|
||||
s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap();
|
||||
let export = json!({
|
||||
"format": "jpg", "quality": 70, "lossless": false, "transparency": false, "scale": 50, "width": 640, "height": 480,
|
||||
"lockAspect": true, "sizing": "pixels", "metadata": "exif", "profile": "srgb", "embedProfile": false, "tgaBits": "24"
|
||||
});
|
||||
s.execute("file.export.presets.save", json!({"name": "Web JPEG", "settings": export})).unwrap();
|
||||
let saved = s.presets.to_json(&s);
|
||||
for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] {
|
||||
for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches", "export_presets"] {
|
||||
let mut blob = saved.clone();
|
||||
blob[key] = json!("not a preset set");
|
||||
let mut next = Session::new();
|
||||
next.load_presets_json(blob);
|
||||
assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`");
|
||||
assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`");
|
||||
if key != "export_presets" {
|
||||
assert_eq!(next.presets.export_presets, s.presets.export_presets, "damaged `{key}`");
|
||||
}
|
||||
}
|
||||
// Null sets keep their defaults, as before.
|
||||
let mut next = Session::new();
|
||||
|
||||
@@ -648,7 +648,10 @@ fn run_scaled(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, scale:
|
||||
crop(crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0, reach_rect)
|
||||
};
|
||||
let small = resize_surface_in_canvas(&src, f, f, Resample::Bilinear, plan.canvas);
|
||||
let small_sel = resize_surface_in_canvas(&crop(sel, reach_rect), f, f, Resample::Bilinear, plan.canvas);
|
||||
// The selection is scaled in 32-bit float: in 8 bits a faint, sparse one (scattered 1/255
|
||||
// pixels) averages to 0 and the scaled solve would find nothing selected.
|
||||
let sel_f32 = photocraft_color::PixelFormat { sample: photocraft_color::SampleType::F32, ..sel.format() };
|
||||
let small_sel = resize_surface_in_canvas(&crop(sel, reach_rect).convert(sel_f32), f, f, Resample::Bilinear, plan.canvas);
|
||||
ctx.check()?;
|
||||
let canvas = scaled_rect(plan.canvas, f, f);
|
||||
let size = photocraft_geom::Size { width: canvas.width(), height: canvas.height() };
|
||||
@@ -758,4 +761,30 @@ mod tests {
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 257, "height": 256})).unwrap();
|
||||
assert!(preview(&s, &json!({"offset": [20, 20]})).unwrap().is_coarse());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coarse_preview_keeps_a_faint_sparse_selection() {
|
||||
// An 8-bit selection of scattered 1/255 pixels: scaled down in 8 bits it averaged to 0
|
||||
// everywhere, so the coarse preview failed with "the selection is empty" while the
|
||||
// command itself moves it.
|
||||
let mut s = textured(1200, 800);
|
||||
s.edit("faint selection", |doc, _| {
|
||||
let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
let r = Rect::new(20, 20, 1120, 720);
|
||||
let data: Vec<f32> = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| if x % 4 == 0 && y % 4 == 0 { 1.0 / 255.0 } else { 0.0 })).collect();
|
||||
sel.write_region(r, &data);
|
||||
doc.selection = Some(sel);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let p = json!({"offset": [40, 40]});
|
||||
let job = preview(&s, &p).unwrap();
|
||||
assert!(job.is_coarse());
|
||||
let (got, damage) = job.run(&JobCtx::new()).unwrap();
|
||||
assert!(!damage.is_empty());
|
||||
let b = got.bounds();
|
||||
let before = s.active().unwrap().doc.layers[0].surface().unwrap().read_region(b);
|
||||
assert!(got.layers[0].surface().unwrap().read_region(b) != before, "the preview moves something");
|
||||
s.execute("paint.contentAwareMove", p).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,8 @@ fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result<Homography> {
|
||||
Ok(t(px + dx, py + dy).mul(&rot).mul(&skew).mul(&scale).mul(&t(-px, -py)))
|
||||
}
|
||||
|
||||
/// Select › Transform Selection: warps the selection outline by the params' matrix, quad or
|
||||
/// scale/rotate/move, refused when the result would not fit the transform budget.
|
||||
fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "select.transformSelection";
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
@@ -206,7 +208,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let selected = s.edit("Transform Selection", |doc, _| {
|
||||
if let Some(sel) = &doc.selection {
|
||||
crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection")?;
|
||||
crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection", sel.format())?;
|
||||
doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
Ok(doc.selection.is_some())
|
||||
|
||||
@@ -257,6 +257,30 @@ pub fn resolved_json(doc: &Document, r: &ResolvedSlice) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// A stored slice as `slice.list` and `slice.set` report it: once, with its own (canvas-clipped)
|
||||
/// rectangle, the one `slice.set` takes back, and the visible `pieces` a later slice cuts it into,
|
||||
/// which are numbered and exported separately (#7770). `number` is its first piece's, `null` when
|
||||
/// later slices cover it wholly. `None` for an unknown id.
|
||||
fn stored_json(doc: &Document, id: u32, resolved: &[ResolvedSlice]) -> Option<Value> {
|
||||
let sl = doc.slices.get(id)?;
|
||||
let pieces: Vec<&ResolvedSlice> = resolved.iter().filter(|r| r.id == Some(id)).collect();
|
||||
let first = pieces.first();
|
||||
let whole = ResolvedSlice {
|
||||
number: first.map_or(0, |r| r.number),
|
||||
id: Some(id),
|
||||
origin: sl.origin,
|
||||
rect: sl.rect.intersect(&doc.bounds()),
|
||||
name: first.map_or_else(|| sl.name.clone(), |r| r.name.clone()),
|
||||
kind: sl.kind,
|
||||
};
|
||||
let mut v = resolved_json(doc, &whole);
|
||||
if first.is_none() {
|
||||
v["number"] = Value::Null;
|
||||
}
|
||||
v["pieces"] = pieces.iter().map(|r| json!({"number": r.number, "name": r.name, "rect": [r.rect.x0, r.rect.y0, r.rect.width(), r.rect.height()]})).collect();
|
||||
Some(v)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn new_slice(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -331,15 +355,7 @@ fn set_slice(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(id)
|
||||
})?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if let Some(r) = slices::resolve(&d.doc).into_iter().find(|r| r.id == Some(id)) {
|
||||
return Ok(resolved_json(&d.doc, &r));
|
||||
}
|
||||
// Wholly under later slices: it has no visible piece, so no number.
|
||||
let sl = d.doc.slices.get(id).ok_or_else(|| bad(cmd, "slice vanished"))?;
|
||||
let r = ResolvedSlice { number: 0, id: Some(id), origin: sl.origin, rect: sl.rect.intersect(&d.doc.bounds()), name: sl.name.clone(), kind: sl.kind };
|
||||
let mut v = resolved_json(&d.doc, &r);
|
||||
v["number"] = Value::Null;
|
||||
Ok(v)
|
||||
stored_json(&d.doc, id, &slices::resolve(&d.doc)).ok_or_else(|| bad(cmd, "slice vanished"))
|
||||
}
|
||||
|
||||
fn promote(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -453,7 +469,22 @@ fn divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn list(s: &mut Session) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let all: Vec<Value> = slices::resolve(&d.doc).iter().map(|r| resolved_json(&d.doc, r)).collect();
|
||||
// One entry per slice, in reading order of its first piece: auto slices as they resolve, a
|
||||
// stored slice once with its pieces; slices wholly covered by later ones follow.
|
||||
let resolved = slices::resolve(&d.doc);
|
||||
let mut listed = std::collections::HashSet::new();
|
||||
let mut all: Vec<Value> = Vec::new();
|
||||
for r in &resolved {
|
||||
match r.id {
|
||||
None => all.push(resolved_json(&d.doc, r)),
|
||||
Some(id) if listed.insert(id) => all.extend(stored_json(&d.doc, id, &resolved)),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let canvas = d.doc.bounds();
|
||||
for sl in d.doc.slices.list.iter().filter(|sl| !listed.contains(&sl.id) && !sl.rect.intersect(&canvas).is_empty()) {
|
||||
all.extend(stored_json(&d.doc, sl.id, &resolved));
|
||||
}
|
||||
Ok(json!({"slices": all, "locked": s.file_menu.slices_locked}))
|
||||
}
|
||||
|
||||
@@ -543,7 +574,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "List Slices",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…}], locked}",
|
||||
params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…,pieces?:[{number,name,rect}] (a stored slice's visible parts; number null when wholly covered)}], locked}",
|
||||
enabled: has_doc,
|
||||
journal: false,
|
||||
run: |s, _| list(s),
|
||||
|
||||
@@ -57,15 +57,31 @@ fn covered_slices_keep_their_options() {
|
||||
let mut s = session(8);
|
||||
let under = s.execute("slice.new", json!({"rect": [0, 0, 60, 60], "name": "under"})).unwrap()["slice"].as_u64().unwrap();
|
||||
s.execute("slice.new", json!({"rect": [40, 40, 60, 50], "name": "over"})).unwrap();
|
||||
let l = s.execute("slice.list", json!({})).unwrap();
|
||||
let pieces: Vec<&Value> = l["slices"].as_array().unwrap().iter().filter(|v| v["id"] == json!(under)).collect();
|
||||
assert_eq!(pieces.len(), 2);
|
||||
assert!(pieces.iter().all(|v| v["name"] == "under"), "Slice Options shows the slice's own name: {pieces:?}");
|
||||
// Moved wholly under the later slice, it is still on the canvas.
|
||||
let listed = |s: &mut Session| -> Vec<Value> {
|
||||
let l = s.execute("slice.list", json!({})).unwrap();
|
||||
l["slices"].as_array().unwrap().iter().filter(|v| v["id"] == json!(under)).cloned().collect()
|
||||
};
|
||||
// Listed once, with its own rectangle and name; the visible pieces are numbered separately.
|
||||
let entries = listed(&mut s);
|
||||
assert_eq!(entries.len(), 1, "{entries:?}");
|
||||
let e = &entries[0];
|
||||
assert_eq!((&e["name"], &e["rect"], &e["number"]), (&json!("under"), &json!([0, 0, 60, 60]), &json!(1)));
|
||||
let pieces = e["pieces"].as_array().unwrap();
|
||||
assert_eq!(pieces.len(), 2, "{pieces:?}");
|
||||
assert_eq!(pieces[0], json!({"number": 1, "name": "under", "rect": [0, 0, 60, 40]}));
|
||||
assert_eq!(pieces[1]["rect"], json!([0, 40, 40, 20]));
|
||||
// Its listed rectangle written back through Slice Options keeps the slice whole.
|
||||
s.execute("slice.set", json!({"slice": under, "rect": e["rect"]})).unwrap();
|
||||
assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(0, 0, 60, 60));
|
||||
// Moved wholly under the later slice, it is still on the canvas, and still listed.
|
||||
let r = s.execute("slice.set", json!({"slice": under, "rect": [50, 50, 10, 10], "alt": "hidden"})).unwrap();
|
||||
assert_eq!(r["number"], Value::Null);
|
||||
assert_eq!(r["alt"], "hidden");
|
||||
assert_eq!(r["pieces"], json!([]));
|
||||
assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(50, 50, 60, 60));
|
||||
let entries = listed(&mut s);
|
||||
assert_eq!(entries.len(), 1, "a wholly covered slice is still listed: {entries:?}");
|
||||
assert_eq!((&entries[0]["number"], &entries[0]["rect"]), (&Value::Null, &json!([50, 50, 10, 10])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,7 +309,9 @@ fn rejected_slice_options_change_nothing() {
|
||||
// A user slice moved off the canvas is rejected without committing the move.
|
||||
let id = s.execute("slice.new", json!({"rect": [0, 0, 10, 10]})).unwrap()["slice"].as_u64().unwrap() as u32;
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
assert!(s.execute("slice.set", json!({"slice": id, "rect": [500, 500, 10, 10]})).is_err());
|
||||
let before = doc(&s).slices.clone();
|
||||
assert!(s.execute("slice.set", json!({"slice": id, "rect": [500, 500, 10, 10], "alt": "gone"})).is_err());
|
||||
assert_eq!(doc(&s).slices, before, "neither the move nor the option is committed");
|
||||
assert_eq!(doc(&s).slices.get(id).unwrap().rect, Rect::new(0, 0, 10, 10));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
|
||||
@@ -283,8 +283,11 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
/// `top` blended over `base` with `mode` at `opacity` (a smart filter's blending options).
|
||||
/// Mix a filter's result with its input, without stacking the same pixel coverage twice.
|
||||
fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) -> Surface {
|
||||
if opacity <= 0.0 {
|
||||
return base.clone();
|
||||
}
|
||||
let fmt = base.format();
|
||||
let area = base.content_bounds().union(&top.content_bounds());
|
||||
let mut out = base.clone();
|
||||
@@ -293,9 +296,38 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let (b, t) = (base.read_region(area), top.read_region(area));
|
||||
// Alpha-preserving filters edit the same coverage. A non-Normal filter that
|
||||
// changes coverage (e.g. blur) must also retain input-only pixels and admit
|
||||
// filtered-only pixels, as Photoshop's Smart Filter blend oracles require.
|
||||
// Classify the whole result so unchanged pixels do not switch blend rules
|
||||
// independently at the edge of a blur.
|
||||
let composite_coverage = mode != BlendMode::Normal && fmt.alpha && b.chunks_exact(n).zip(t.chunks_exact(n)).any(|(bp, tp)| bp.last() != tp.last());
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for ((bp, tp), op) in b.chunks_exact(n).zip(t.chunks_exact(n)).zip(o.chunks_exact_mut(n)) {
|
||||
let rgba = photocraft_color::blend::composite(mode, photocraft_raster::to_rgba(&fmt, bp), photocraft_raster::to_rgba(&fmt, tp), opacity);
|
||||
let b = photocraft_raster::to_rgba(&fmt, bp);
|
||||
let t = photocraft_raster::to_rgba(&fmt, tp);
|
||||
if composite_coverage {
|
||||
let rgba = photocraft_color::blend::composite(mode, b, t, opacity);
|
||||
photocraft_raster::from_rgba_into(&fmt, rgba, op);
|
||||
continue;
|
||||
}
|
||||
let cb = [b[0], b[1], b[2]];
|
||||
let ct = [t[0], t[1], t[2]];
|
||||
let color = if fmt.sample == photocraft_color::SampleType::F32 {
|
||||
photocraft_color::blend::blend_rgb_hdr(mode, cb, ct)
|
||||
} else {
|
||||
photocraft_color::blend::blend_rgb(mode, cb, ct)
|
||||
};
|
||||
// A filter edits this surface, rather than adding a second source-over layer. Mix
|
||||
// premultiplied results so alpha-preserving filters keep coverage at every opacity,
|
||||
// while Normal-mode filters that change coverage interpolate it too.
|
||||
let a = b[3] + (t[3] - b[3]) * opacity;
|
||||
let mut rgba = [0.0, 0.0, 0.0, a];
|
||||
if a > 0.0 {
|
||||
for c in 0..3 {
|
||||
rgba[c] = (cb[c] * b[3] * (1.0 - opacity) + color[c] * t[3] * opacity) / a;
|
||||
}
|
||||
}
|
||||
photocraft_raster::from_rgba_into(&fmt, rgba, op);
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
@@ -314,7 +346,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
|
||||
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
|
||||
let extent = canvas.union(&placed.content_bounds());
|
||||
for f in sm.smart_filters.iter().filter(|f| f.visible) {
|
||||
cur = apply_smart_filter(f, &cur, canvas, extent)?;
|
||||
cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent)?;
|
||||
}
|
||||
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
|
||||
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
|
||||
@@ -322,6 +354,21 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
|
||||
})
|
||||
}
|
||||
|
||||
/// The reference bounds of smart filter `f`. Render filters work within the smart object, as
|
||||
/// Photoshop's do: Lens Flare's centre is a fraction of the object, and Clouds and Fibers fill it
|
||||
/// and size their pattern by it. The object is its placed content's bounds (which leaves out
|
||||
/// fully transparent edges of the source); the other filters use the canvas, as on a layer.
|
||||
fn filter_bounds(f: &SmartFilter, placed: &Surface, canvas: Rect) -> Rect {
|
||||
match placed.content_bounds() {
|
||||
b if renders_in_object(f) && !b.is_empty() => b,
|
||||
_ => canvas,
|
||||
}
|
||||
}
|
||||
|
||||
fn renders_in_object(f: &SmartFilter) -> bool {
|
||||
matches!(f.command.as_str(), "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.render.lensFlare")
|
||||
}
|
||||
|
||||
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
|
||||
/// requesting session's authority denies the filter's params: callers surface the error instead of
|
||||
/// rendering (and caching) unfiltered pixels.
|
||||
@@ -424,10 +471,18 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
|
||||
None => (0, placed()?),
|
||||
};
|
||||
let n = filters.len() as f32;
|
||||
// The object's bounds, found once: the placed stage may have been evicted from the cache, and
|
||||
// placing it again for every render filter would decode the source each time.
|
||||
let mut object_bounds: Option<Rect> = None;
|
||||
for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) {
|
||||
ctx.check()?;
|
||||
ctx.progress(i as f32 / n, "");
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent };
|
||||
let bounds = match object_bounds {
|
||||
_ if !renders_in_object(f) => canvas,
|
||||
Some(b) => b,
|
||||
None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)),
|
||||
};
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent)?, extent: cur.extent };
|
||||
stack_cache::put(*key, &cur);
|
||||
}
|
||||
ctx.progress(1.0, "");
|
||||
@@ -1018,10 +1073,40 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The Displace filter, whose map a smart filter keeps embedded in its params.
|
||||
const DISPLACE: &str = "filter.distort.displace";
|
||||
|
||||
fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.smartFilter.setParams";
|
||||
let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?;
|
||||
let authorize = s.authorize;
|
||||
// A Displace given another open document as its map embeds that one in place of the old
|
||||
// (#7423). It is read here, before the edit, from the session; the request is authorized
|
||||
// first, as the merged params are below. A new `mapPath` stays a path (read next to the
|
||||
// document on each re-render) and only drops the old embedded map; an empty one, or a
|
||||
// negative `mapDocument` (the dialog's "None"), names no map.
|
||||
let new_map_asked = crate::filters_ext::map_document(&new).is_some();
|
||||
let new_path = crate::filters_ext::map_path(&new).is_some();
|
||||
let displace = {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let sm = smart(doc, layer_param(s, p)?)?;
|
||||
sm.smart_filters.get(filter_index(CMD, p, sm)?).is_some_and(|f| f.command == DISPLACE)
|
||||
};
|
||||
let new_map = if displace && new_map_asked {
|
||||
// The old embedded map, if the edit passed it back, gives way to the one named.
|
||||
let request = crate::filters_ext::displace_request(&new);
|
||||
if let Some(gate) = authorize {
|
||||
gate(DISPLACE, &request)?;
|
||||
}
|
||||
let mut fp = crate::filters::params_for(DISPLACE, &request).ok_or_else(|| bad(CMD, "unreadable Displace settings"))?;
|
||||
crate::filters_ext::resolve(s, &mut fp, &request)?;
|
||||
match fp {
|
||||
photocraft_algo::FilterParams::Displace { map, .. } => map,
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
edit_filters(s, p, "Edit Smart Filter", |sm| {
|
||||
let i = filter_index(CMD, p, sm)?;
|
||||
let f = &mut sm.smart_filters[i];
|
||||
@@ -1032,6 +1117,14 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Value::Object(old), Value::Object(n)) => old.extend(n),
|
||||
(slot, n) => *slot = n,
|
||||
}
|
||||
if f.command == DISPLACE {
|
||||
if let Some(map) = &new_map {
|
||||
f.params = photocraft_io::smart_map::displace_embed_map(&f.params, map)
|
||||
.ok_or_else(|| bad(CMD, "the displacement map is too large to keep in a Smart Object's filter"))?;
|
||||
} else if new_path {
|
||||
f.params = photocraft_io::smart_map::displace_without_map(&f.params);
|
||||
}
|
||||
}
|
||||
// Authorize the underlying command with the complete merged params, including paths
|
||||
// already stored in the document, before committing the edit or starting a worker.
|
||||
if let Some(gate) = authorize {
|
||||
|
||||
@@ -7,6 +7,77 @@ const DEPTHS: [u64; 3] = [8, 16, 32];
|
||||
const W: i32 = 64;
|
||||
const H: i32 = 48;
|
||||
|
||||
/// A partial filter strength keeps the source alpha and interpolates the filtered coverage, at every sample depth.
|
||||
#[test]
|
||||
fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() {
|
||||
for sample in photocraft_color::SampleType::ALL {
|
||||
let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true };
|
||||
let mut base = Surface::new(fmt);
|
||||
let mut filtered = Surface::new(fmt);
|
||||
base.fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.4, 0.6, 0.5]);
|
||||
filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.5]);
|
||||
let before = base.pixel(0, 0);
|
||||
let after = filtered.pixel(0, 0);
|
||||
let tolerance = match sample {
|
||||
photocraft_color::SampleType::U8 => 1.0 / 255.0,
|
||||
photocraft_color::SampleType::U16 => 1.0 / 65535.0,
|
||||
photocraft_color::SampleType::F32 => 1e-6,
|
||||
};
|
||||
for opacity in [0.0, 0.01, 0.5, 1.0] {
|
||||
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Luminosity] {
|
||||
let mixed = blend_surfaces(&base, &filtered, mode, opacity).pixel(0, 0);
|
||||
assert!((mixed[3] - before[3]).abs() <= tolerance);
|
||||
if mode == BlendMode::Normal {
|
||||
for c in 0..3 {
|
||||
assert!((mixed[c] - (before[c] + (after[c] - before[c]) * opacity)).abs() <= tolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.0]);
|
||||
let mixed = blend_surfaces(&base, &filtered, BlendMode::Normal, 0.5).pixel(0, 0);
|
||||
assert!((mixed[3] - before[3] * 0.5).abs() <= tolerance);
|
||||
for c in 0..3 {
|
||||
assert!((mixed[c] - before[c]).abs() <= tolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blending a filter result in Screen, Multiply or Luminosity keeps source-over coverage where the filter changed alpha.
|
||||
#[test]
|
||||
fn non_normal_coverage_changes_retain_source_over_semantics() {
|
||||
for sample in photocraft_color::SampleType::ALL {
|
||||
let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true };
|
||||
let mut base = Surface::new(fmt);
|
||||
let mut filtered = Surface::new(fmt);
|
||||
// Input-only, output-only, opaque input with blurred output, and a
|
||||
// translucent pixel whose alpha happens to be unchanged by this filter.
|
||||
for (x, (ba, ta)) in [(0.5, 0.0), (0.0, 0.5), (1.0, 0.25), (0.5, 0.5)].into_iter().enumerate() {
|
||||
let x = x as i32;
|
||||
base.fill_rect(Rect::new(x, 0, 1, 1), &[0.2, 0.4, 0.6, ba]);
|
||||
filtered.fill_rect(Rect::new(x, 0, 1, 1), &[0.8, 0.6, 0.4, ta]);
|
||||
}
|
||||
for mode in [BlendMode::Screen, BlendMode::Multiply, BlendMode::Luminosity] {
|
||||
for opacity in [0.0, 0.01, 0.5, 1.0] {
|
||||
let mixed = blend_surfaces(&base, &filtered, mode, opacity);
|
||||
for x in 0..4 {
|
||||
if opacity == 0.0 {
|
||||
assert_eq!(mixed.pixel(x, 0), base.pixel(x, 0));
|
||||
continue;
|
||||
}
|
||||
let b = photocraft_raster::to_rgba(&fmt, &base.pixel(x, 0));
|
||||
let t = photocraft_raster::to_rgba(&fmt, &filtered.pixel(x, 0));
|
||||
let expected = photocraft_color::blend::composite(mode, b, t, opacity);
|
||||
let mut reference = Surface::new(fmt);
|
||||
reference.fill_rect(Rect::new(x, 0, 1, 1), &expected);
|
||||
assert_eq!(mixed.pixel(x, 0), reference.pixel(x, 0), "{sample:?} {mode:?} {opacity} x={x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `depth`-bit document with one empty layer.
|
||||
fn session(depth: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap();
|
||||
@@ -1139,3 +1210,83 @@ fn displace_smart_filter_reads_its_map_path_on_re_render() {
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clouds and Difference Clouds on a smart object are smart filters, as in Photoshop, and keep
|
||||
/// the colours they were drawn with: re-rendering after the colours change gives the same clouds.
|
||||
#[test]
|
||||
fn clouds_on_a_smart_object_are_smart_filters_with_their_colours() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth);
|
||||
paint(&mut s);
|
||||
s.execute("filter.convertForSmartFilters", json!({})).unwrap();
|
||||
s.tools.foreground = [1.0, 0.0, 0.0, 1.0];
|
||||
s.tools.background = [0.0, 0.0, 1.0, 1.0];
|
||||
s.execute("filter.render.clouds", json!({"seed": 5})).unwrap();
|
||||
s.execute("filter.render.differenceClouds", json!({"seed": 9})).unwrap();
|
||||
let sm = active_smart(&s);
|
||||
let commands: Vec<&str> = sm.smart_filters.iter().map(|f| f.command.as_str()).collect();
|
||||
assert_eq!(commands, ["filter.render.clouds", "filter.render.differenceClouds"]);
|
||||
assert_eq!(sm.smart_filters[0].params["foreground"], json!([1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(sm.smart_filters[1].params["background"], json!([0.0, 0.0, 1.0, 1.0]));
|
||||
// Red-to-blue clouds, and their difference, have no green: they fill the object (painted
|
||||
// from (-6, 5) to (40, 38)) over the white background.
|
||||
let rendered = flat(&s);
|
||||
let object: Vec<[f32; 4]> = (5..38).flat_map(|y| (0..40).map(move |x| (y * W + x) as usize)).map(|i| rendered[i]).collect();
|
||||
assert!(object.iter().all(|p| p[1] < 0.02), "depth {depth}");
|
||||
assert!(max_diff(&object, &vec![object[0]; object.len()]) > 0.05, "the clouds vary");
|
||||
s.tools.foreground = [0.0, 1.0, 0.0, 1.0];
|
||||
s.execute("layer.smartFilter.setParams", json!({"index": 1, "params": {"seed": 9}})).unwrap();
|
||||
assert!(max_diff(&flat(&s), &rendered) < 1e-6, "depth {depth}: same params, same clouds");
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert_eq!(active_smart(&s).smart_filters.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render filters on a smart object work within the object, as in Photoshop (and as PSD stores
|
||||
/// them): Lens Flare's centre is a fraction of the object's bounds, not of the canvas.
|
||||
#[test]
|
||||
fn lens_flare_centres_on_the_smart_object() {
|
||||
let mut s = session(8);
|
||||
let block = Rect::new(32, 24, W, H);
|
||||
s.edit("setup", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let px = photocraft_raster::from_rgba(&fmt, [0.1, 0.1, 0.1, 1.0]);
|
||||
surf.write_region(block, &px.repeat((block.width() * block.height()) as usize));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
convert(&mut s);
|
||||
s.execute("filter.render.lensFlare", json!({"centerX": 0.5, "centerY": 0.5, "lens": "prime105"})).unwrap();
|
||||
// The centre of the brightest pixels inside the object (the canvas around it is white).
|
||||
let px = flat(&s);
|
||||
let inside: Vec<(i32, i32, f32)> =
|
||||
(block.y0..block.y1).flat_map(|y| (block.x0..block.x1).map(move |x| (x, y))).map(|(x, y)| (x, y, px[(y * W + x) as usize][1])).collect();
|
||||
let peak = inside.iter().map(|p| p.2).fold(0.0, f32::max);
|
||||
let core: Vec<_> = inside.iter().filter(|p| p.2 >= peak - 0.02).collect();
|
||||
let (x, y) = (core.iter().map(|p| p.0).sum::<i32>() / core.len() as i32, core.iter().map(|p| p.1).sum::<i32>() / core.len() as i32);
|
||||
assert!((x - 48).abs() <= 1 && (y - 36).abs() <= 1, "flare at {x},{y}, the object's centre is 48,36");
|
||||
}
|
||||
|
||||
/// Moving a smart object moves its Clouds and Fibers with it: the pattern is anchored at the
|
||||
/// object, not at the canvas, so re-rendering after the move doesn't change the texture.
|
||||
#[test]
|
||||
fn clouds_and_fibers_move_with_the_smart_object() {
|
||||
for (command, params) in [("filter.render.clouds", json!({"seed": 3})), ("filter.render.fibers", json!({"seed": 3}))] {
|
||||
let mut s = session(8);
|
||||
paint(&mut s);
|
||||
let id = convert(&mut s);
|
||||
s.execute(command, params).unwrap();
|
||||
let before = flat(&s);
|
||||
s.execute("edit.transform", json!({"layer": id, "matrix": [1, 0, 0, 1, 8, 6]})).unwrap();
|
||||
let after = flat(&s);
|
||||
// Inside the object (painted from (-6, 5) to (40, 38)) both before and after the move.
|
||||
for y in 8..30 {
|
||||
for x in 2..30 {
|
||||
let (a, b) = (before[(y * W + x) as usize], after[((y + 6) * W + x + 8) as usize]);
|
||||
assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "{command} at {x},{y}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,8 @@ fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection
|
||||
/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the
|
||||
/// selection's, so a moved selection snapped back to where it started).
|
||||
fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect)
|
||||
fn target_rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(doc, l)).and_then(to_rect)
|
||||
}
|
||||
|
||||
fn snappable(l: &photocraft_doc::Layer) -> bool {
|
||||
@@ -132,7 +132,7 @@ impl SnapTargets {
|
||||
if !l.visible || exclude.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = target_rect_of(l) {
|
||||
if let Some(r) = target_rect_of(doc, l) {
|
||||
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +362,7 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Aco,
|
||||
Act,
|
||||
Ase,
|
||||
}
|
||||
|
||||
@@ -369,6 +370,7 @@ impl Format {
|
||||
fn parse(s: &str) -> Option<Format> {
|
||||
match s.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"aco" => Some(Format::Aco),
|
||||
"act" => Some(Format::Act),
|
||||
"ase" => Some(Format::Ase),
|
||||
_ => None,
|
||||
}
|
||||
@@ -376,6 +378,7 @@ impl Format {
|
||||
fn id(self) -> &'static str {
|
||||
match self {
|
||||
Format::Aco => "aco",
|
||||
Format::Act => "act",
|
||||
Format::Ase => "ase",
|
||||
}
|
||||
}
|
||||
@@ -462,6 +465,21 @@ fn kind_to_ase(k: SwatchKind) -> AseKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colors in an `.act` color table (what the format holds).
|
||||
pub const ACT_COLORS: usize = photocraft_psd::act::COLOR_COUNT;
|
||||
|
||||
/// `.act` bytes as `#rrggbb` colors plus the transparent index, for the Color Table dialog.
|
||||
pub fn act_table(bytes: &[u8]) -> std::result::Result<(Vec<String>, Option<u64>), String> {
|
||||
let f = photocraft_psd::act::parse(bytes).map_err(|e| e.to_string())?;
|
||||
let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]);
|
||||
Ok((f.used().iter().map(hex).collect(), f.transparent_index().map(|i| i as u64)))
|
||||
}
|
||||
|
||||
/// Encode colors as `.act` bytes (at most [`ACT_COLORS`]); the transparent index is kept.
|
||||
pub fn act_bytes(colors: &[[u8; 3]], transparent: Option<u16>) -> std::result::Result<Vec<u8>, String> {
|
||||
photocraft_psd::act::write(colors, Some(colors.len().min(ACT_COLORS) as u16), transparent).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Parse a swatch file into groups (`group` names the group for ungrouped colours).
|
||||
pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Result<(Vec<Group<Swatch>>, Vec<String>), String> {
|
||||
let mut warnings = Vec::new();
|
||||
@@ -484,6 +502,19 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu
|
||||
.collect();
|
||||
out.push(Group::new(group, items));
|
||||
}
|
||||
Format::Act => {
|
||||
let f = photocraft_psd::act::parse(bytes).map_err(|e| format!("not a readable .act color table: {e}"))?;
|
||||
if let Some(t) = f.transparent_index() {
|
||||
warnings.push(format!("color {} is the table's transparent color; swatches have no transparency so it was kept opaque", t + 1));
|
||||
}
|
||||
let items = f
|
||||
.used()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| Swatch { name: format!("Swatch {}", i + 1), color: SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0)), kind: SwatchKind::Process })
|
||||
.collect();
|
||||
out.push(Group::new(group, items));
|
||||
}
|
||||
Format::Ase => {
|
||||
let f = photocraft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?;
|
||||
let mut skipped = 0;
|
||||
@@ -541,6 +572,32 @@ pub fn write_file(groups: &[Group<Swatch>], format: Format) -> std::result::Resu
|
||||
let bytes = photocraft_psd::aco::write(&swatches).map_err(|e| e.to_string())?;
|
||||
Ok((bytes, n, warnings))
|
||||
}
|
||||
Format::Act => {
|
||||
// `.act` holds RGB only: HSB rides along as RGB, anything else is left out.
|
||||
let rgb8 = |v: [f32; 3]| v.map(|c| (unit(c) * 255.0).round() as u8);
|
||||
let mut triplets = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for s in groups.iter().flat_map(|g| g.items.iter()) {
|
||||
match s.color {
|
||||
SwatchColor::Rgb(v) => triplets.push(rgb8(v)),
|
||||
SwatchColor::Hsb(v) => triplets.push(rgb8(hsb_to_rgb(v))),
|
||||
_ => skipped += 1,
|
||||
}
|
||||
}
|
||||
if groups.len() > 1 {
|
||||
warnings.push(".act files have no groups; the swatches were saved as one list".into());
|
||||
}
|
||||
if skipped > 0 {
|
||||
warnings.push(format!("{skipped} non-RGB swatches can't be stored in an .act file and were left out (save as .aco to keep them)"));
|
||||
}
|
||||
if triplets.len() > photocraft_psd::act::COLOR_COUNT {
|
||||
warnings.push(format!("only the first {} swatches fit in an .act file", photocraft_psd::act::COLOR_COUNT));
|
||||
triplets.truncate(photocraft_psd::act::COLOR_COUNT);
|
||||
}
|
||||
let n = triplets.len();
|
||||
let bytes = photocraft_psd::act::write(&triplets, Some(n as u16), None).map_err(|e| e.to_string())?;
|
||||
Ok((bytes, n, warnings))
|
||||
}
|
||||
Format::Ase => {
|
||||
let mut books = 0;
|
||||
let mut entries = Vec::with_capacity(groups.len());
|
||||
@@ -571,12 +628,18 @@ pub fn write_file(groups: &[Group<Swatch>], format: Format) -> std::result::Resu
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "swatches.import";
|
||||
let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco or .ase file")?;
|
||||
let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco, .act or .ase file")?;
|
||||
// `.act` has no magic: exactly 768/772 bytes. An `.act` whose first colour is `0, 1, x` or
|
||||
// `0, 2, x` starts like an `.aco`, and the .aco reader ignores trailing bytes, so at those
|
||||
// lengths an .aco header only counts when its sections fill the file exactly.
|
||||
let act_len = bytes.len() == photocraft_psd::act::PALETTE_LEN || bytes.len() == photocraft_psd::act::PALETTE_LEN + photocraft_psd::act::TRAILER_LEN;
|
||||
let format = match str_param(p, "format") {
|
||||
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?,
|
||||
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?,
|
||||
None if ext_format(str_param(p, "path")) == Some(Format::Act) => Format::Act,
|
||||
None if bytes.starts_with(photocraft_psd::ase::SIGNATURE) => Format::Ase,
|
||||
None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) => Format::Aco,
|
||||
None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco or .ase swatch file"))?,
|
||||
None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) && (!act_len || photocraft_psd::aco::sections_len(&bytes) == Some(bytes.len())) => Format::Aco,
|
||||
None if act_len => Format::Act,
|
||||
None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco, .act or .ase swatch file"))?,
|
||||
};
|
||||
let mode = str_param(p, "mode").unwrap_or("append");
|
||||
let replace = match mode {
|
||||
@@ -634,7 +697,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "swatches.export";
|
||||
let path = str_param(p, "path");
|
||||
let format = match str_param(p, "format") {
|
||||
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?,
|
||||
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?,
|
||||
None => ext_format(path).unwrap_or(Format::Aco),
|
||||
};
|
||||
let mut groups: Vec<Group<Swatch>> = match name_param(p, "group", CMD)? {
|
||||
@@ -790,7 +853,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Import Swatches…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".aco or .ase file"?,"data":base64 bytes?,"format":"aco|ase"? (detected),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##,
|
||||
params: r##"{"path":".aco, .act or .ase file"?,"data":base64 bytes?,"format":"aco|act|ase"? (detected; an .act by its .act path or its 768/772-byte length),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##,
|
||||
enabled: always,
|
||||
run: import,
|
||||
journal: true,
|
||||
@@ -800,7 +863,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Export Swatches…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"format":"aco|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##,
|
||||
params: r##"{"format":"aco|act|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##,
|
||||
enabled: has_swatches,
|
||||
run: export,
|
||||
journal: true,
|
||||
|
||||
@@ -146,6 +146,64 @@ fn aco_round_trip_keeps_16_bit_values_and_books() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn act_round_trip_loads_a_photoshop_color_table() {
|
||||
// A Photoshop-saved table: 256 RGB colors plus the count/transparent trailer.
|
||||
let mut colors = [[0u8; 3]; 256];
|
||||
colors[0] = [255, 255, 255];
|
||||
for (i, c) in colors.iter_mut().enumerate().skip(1) {
|
||||
let v = i as u8;
|
||||
*c = [v, 255 - v, 128];
|
||||
}
|
||||
let file = photocraft_psd::act::write(&colors, Some(256), Some(255)).unwrap();
|
||||
assert_eq!(file.len(), 772);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Table", "mode": "replace"})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256)));
|
||||
assert_eq!(r["warnings"].as_array().unwrap().len(), 1, "the transparent color is reported");
|
||||
assert_eq!(s.presets.swatches[0].items[0].color, SwatchColor::Rgb([1.0, 1.0, 1.0]));
|
||||
assert_eq!(s.presets.swatches[0].items[1].name, "Swatch 2");
|
||||
// Sniffed by length without a format, and by extension from a path.
|
||||
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap();
|
||||
assert_eq!(r["format"].as_str(), Some("act"));
|
||||
// Export keeps the 256 RGB colors; the trailer comes back in canonical form
|
||||
// (swatches have no transparency to preserve).
|
||||
let canon = photocraft_psd::act::write(&colors, Some(256), None).unwrap();
|
||||
assert_eq!(export_bytes(&mut s, json!({"format": "act"})), canon);
|
||||
// A bigger library is capped at 256 with a warning; groups collapse to one list.
|
||||
for i in 0..300 {
|
||||
s.execute("swatches.add", json!({"name": format!("Extra {i}"), "color": {"model": "rgb", "values": [1, 0, 0]}})).unwrap();
|
||||
}
|
||||
let r = s.execute("swatches.export", json!({"format": "act"})).unwrap();
|
||||
assert_eq!(r["count"], 256);
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("first 256"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn act_that_starts_like_an_aco_is_still_an_act() {
|
||||
// First colour 0, 1, 0 and second colour 5, …: the first four bytes read as an .aco version 1
|
||||
// header with five swatches, and the .aco reader would take 50 bytes of junk from it.
|
||||
let mut colors = [[0u8; 3]; 256];
|
||||
colors[0] = [0, 1, 0];
|
||||
for (i, c) in colors.iter_mut().enumerate().skip(1) {
|
||||
*c = [(i as u8).wrapping_add(4), 7, 9];
|
||||
}
|
||||
for file in [photocraft_psd::act::write(&colors, None, None).unwrap(), photocraft_psd::act::write(&colors, Some(256), None).unwrap()] {
|
||||
assert_eq!(&file[..4], &[0, 1, 0, 5]);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256)), "{} bytes", file.len());
|
||||
}
|
||||
// A real .aco of exactly 768 bytes (ten swatches with 25-character names) stays an .aco.
|
||||
let swatches: Vec<AcoSwatch> = (0..10u16).map(|i| AcoSwatch { name: format!("{i:0>25}"), color: AcoColor::Rgb([i * 1000, 0, 65535]) }).collect();
|
||||
let file = photocraft_psd::aco::write(&swatches).unwrap();
|
||||
assert_eq!(file.len(), photocraft_psd::act::PALETTE_LEN);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("aco"), Some(10)));
|
||||
assert_eq!(s.presets.swatches[0].items[9].name, format!("{:0>25}", 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_round_trip_keeps_groups_floats_and_kinds() {
|
||||
let src = AseFile {
|
||||
|
||||
@@ -74,9 +74,20 @@ impl PaintingSymmetry {
|
||||
Self::Preset(p) => {
|
||||
// The first axis' direction through the map's linear part gives the new angle; a
|
||||
// flip mirrors it.
|
||||
let [dx, dy] = *p.directions().first()?;
|
||||
let directions = p.directions();
|
||||
let [dx, dy] = *directions.first()?;
|
||||
let [m0, m1, m2, m3, _, _] = a.m;
|
||||
let turn = (m1 * dx + m3 * dy).atan2(m0 * dx + m2 * dy) - dy.atan2(dx);
|
||||
let mapped = |[dx, dy]: [f64; 2]| (m1 * dx + m3 * dy).atan2(m0 * dx + m2 * dy);
|
||||
let mut axis = mapped([dx, dy]);
|
||||
// A non-uniform Image Size leaves a turned dual preset's two axes non-perpendicular,
|
||||
// which it can't represent: the new axes split the difference, each turned off its
|
||||
// mapped axis by the same angle. Maps that keep right angles give no difference.
|
||||
if let Some(&second) = directions.get(1) {
|
||||
let off = (mapped(second) - axis - std::f64::consts::FRAC_PI_2).rem_euclid(std::f64::consts::PI);
|
||||
let off = if off > std::f64::consts::FRAC_PI_2 { off - std::f64::consts::PI } else { off };
|
||||
axis += off / 2.0;
|
||||
}
|
||||
let turn = axis - dy.atan2(dx);
|
||||
let rotation = (p.rotation + turn.to_degrees()).rem_euclid(360.0);
|
||||
if !rotation.is_finite() {
|
||||
return None;
|
||||
@@ -288,6 +299,15 @@ mod tests {
|
||||
let p = preset(&s);
|
||||
assert_eq!(p.center, [90.0, 40.0], "edge coordinates mirror about the canvas width, not the last pixel");
|
||||
assert!((p.rotation - 90.0).abs() < 1e-9, "the 45° axis mirrors to 135°: {}", p.rotation);
|
||||
// A turned dual preset under a non-uniform Image Size: its axes (135° and 45°) map to
|
||||
// 153.4° and 26.6°, no longer perpendicular; the preset splits the difference.
|
||||
s.execute("paint.setSymmetry", json!({"mode": "dual", "center": [30, 40], "rotation": 45})).unwrap();
|
||||
s.execute("image.imageSize", json!({"width": 240, "height": 200})).unwrap();
|
||||
let p = preset(&s);
|
||||
assert_eq!(p.center, [60.0, 40.0]);
|
||||
assert!((p.rotation - 45.0).abs() < 1e-9, "both axes 18.4° off, not one 0° and one 36.9°: {}", p.rotation);
|
||||
s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap();
|
||||
assert!((preset(&s).rotation - 315.0).abs() < 1e-9, "a flip keeps right angles (axes at 45° and 135° again): {}", preset(&s).rotation);
|
||||
|
||||
// A path axis keeps lying on its (moved) source path.
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -158,6 +158,33 @@ fn file_new_keeps_defaults_and_supported_modes_and_depths() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_new_tags_the_document_with_the_chosen_profile() {
|
||||
// #321: New Document › Color Profile, as in Photoshop: another profile than the working space.
|
||||
let desc = |s: &Session| {
|
||||
let bytes = s.active().unwrap().doc.icc_profile.clone();
|
||||
bytes.map(|b| photocraft_cms::Profile::parse(&b).unwrap().description)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "profile": "adobe-rgb-compat"})).unwrap();
|
||||
assert_eq!(desc(&s).as_deref(), Some(photocraft_cms::Builtin::AdobeRgbCompat.description()));
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk", "profile": "coated-cmyk"})).unwrap();
|
||||
assert!(desc(&s).is_some());
|
||||
// The working space (the default, or asked for) leaves it as before.
|
||||
for p in [json!({"width": 4, "height": 4}), json!({"width": 4, "height": 4, "profile": "working"})] {
|
||||
s.execute("file.new", p).unwrap();
|
||||
assert_eq!(desc(&s), None);
|
||||
}
|
||||
// A profile that doesn't suit the mode, an unknown one or a wrong type opens nothing.
|
||||
let open = s.documents().len();
|
||||
let (active, steps) = (s.active().unwrap().doc.id, s.active().unwrap().history.past_len());
|
||||
for bad in [json!("coated-cmyk"), json!("no-such-profile"), json!(""), json!(7), json!(null)] {
|
||||
assert!(s.execute("file.new", json!({"width": 4, "height": 4, "profile": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.documents().len(), open, "{bad}");
|
||||
assert_eq!((s.active().unwrap().doc.id, s.active().unwrap().history.past_len()), (active, steps), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_new_variants() {
|
||||
let mut s = Session::new();
|
||||
@@ -1188,6 +1215,55 @@ fn selecting_a_layer_does_not_dirty_the_document() {
|
||||
assert!(s.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_tools_do_not_dirty_the_document() {
|
||||
// #6865: marquee, lasso, Select All / Inverse / Deselect and their undo and redo are
|
||||
// history steps, but not edits, so a clean document stays clean.
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth})).unwrap();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 30, "y": 20, "width": 8, "height": 8, "ellipse": true, "mode": "add"})).unwrap();
|
||||
s.execute("select.lasso", json!({"points": [[0, 0], [40, 0], [20, 30]], "mode": "subtract"})).unwrap();
|
||||
s.execute("select.inverse", json!({})).unwrap();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().history.past_len() > past, "selections are still history steps");
|
||||
assert!(!s.active().unwrap().is_dirty(), "depth {depth}: selecting is not an edit");
|
||||
assert!(s.undo() && s.undo());
|
||||
assert!(s.redo());
|
||||
assert!(!s.active().unwrap().is_dirty(), "depth {depth}: undoing a selection is not an edit");
|
||||
s.execute("history.selectState", json!({"state": past})).unwrap();
|
||||
assert!(!s.active().unwrap().is_dirty(), "depth {depth}: stepping back over selections is not an edit");
|
||||
// A real edit still dirties, with or without a selection, and a selection can't clean it.
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().is_dirty());
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().is_dirty());
|
||||
assert!(s.undo());
|
||||
assert!(s.active().unwrap().is_dirty(), "the new layer is still unsaved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selections_keep_a_document_with_a_video_layer_clean() {
|
||||
// #6865: video frames compare by tile pointer, so selecting in a document with a video
|
||||
// layer doesn't mark it modified either; changing the frames still does.
|
||||
let mut s = session_with_doc();
|
||||
s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
assert!(!s.active().unwrap().is_dirty(), "selecting is not an edit");
|
||||
assert!(s.undo());
|
||||
assert!(!s.active().unwrap().is_dirty(), "undoing a selection is not an edit");
|
||||
s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().is_dirty(), "a new frame is an edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_edits_share_one_history_step() {
|
||||
let mut s = Session::new();
|
||||
@@ -1639,3 +1715,30 @@ fn merge_down_on_a_group_merges_the_group() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some());
|
||||
}
|
||||
|
||||
/// `restore_layer_selection` puts a multi-layer selection back, drops gone layers, keeps a clean
|
||||
/// document clean, and refuses a gone active layer without changing anything.
|
||||
#[test]
|
||||
fn restore_layer_selection_puts_back_what_still_exists() {
|
||||
let mut s = session_with_doc();
|
||||
let id = |v: Value| LayerId(v["layer"].as_u64().unwrap());
|
||||
let a = id(s.execute("layer.new.layer", json!({})).unwrap());
|
||||
let b = id(s.execute("layer.new.layer", json!({})).unwrap());
|
||||
let gone = LayerId(u64::MAX);
|
||||
s.active_mut().unwrap().saved_revision = s.active().unwrap().revision;
|
||||
s.restore_layer_selection(Some(b), &[a, gone, b], Some(a)).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (Some(b), vec![a, b], Some(a)));
|
||||
assert_eq!(st.saved_revision, st.revision, "selection is not an edit");
|
||||
let snap = |s: &Session| {
|
||||
let st = s.active().unwrap();
|
||||
(st.active_layer, st.selected_layers(), st.layer_anchor, st.revision, st.saved_revision)
|
||||
};
|
||||
let before = snap(&s);
|
||||
assert!(matches!(s.restore_layer_selection(Some(gone), &[gone], None), Err(EngineError::NoLayer(_))));
|
||||
assert_eq!(snap(&s), before, "an error changes nothing");
|
||||
s.restore_layer_selection(None, &[a], Some(a)).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected");
|
||||
assert!(Session::new().restore_layer_selection(None, &[], None).is_err(), "no document");
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Edit › Free Transform / Transform: projective transforms of layers (and the selection).
|
||||
|
||||
use photocraft_algo::transform::{Homography, Interp, warp_output_size, warp_surface, warp_surface_with};
|
||||
use photocraft_algo::transform::{Homography, Interp, WARP_REACH, warp_output_clipped, warp_output_size, warp_surface, warp_surface_with};
|
||||
use photocraft_color::PixelFormat;
|
||||
#[cfg(test)]
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -29,7 +32,7 @@ pub fn moves_whole(layer: &Layer) -> bool {
|
||||
/// content within the selection, or all of it for a layer that [`moves_whole`] (#2630).
|
||||
/// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag.
|
||||
pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
|
||||
let content = whole_layer_bounds(layer);
|
||||
let content = whole_layer_bounds(doc, layer);
|
||||
match &doc.selection {
|
||||
Some(sel) if !moves_whole(layer) => content.intersect(&sel.content_bounds()),
|
||||
_ => content,
|
||||
@@ -54,12 +57,12 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
|
||||
}
|
||||
|
||||
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
|
||||
pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect {
|
||||
pub(crate) fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect {
|
||||
let content = match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
// A soft shape (Density below 100 % or a Feather) renders past its path, over the whole
|
||||
// canvas below 100 %: its frame follows the path and stroke, as in Photoshop (#5846).
|
||||
LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::hard_shape_bounds(sh).unwrap_or(Rect::EMPTY),
|
||||
LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::soft_shape_frame(sh, doc.pixel_format(), doc.bounds()).unwrap_or(Rect::EMPTY),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
|
||||
};
|
||||
if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content }
|
||||
@@ -260,30 +263,66 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses
|
||||
/// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not
|
||||
/// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused
|
||||
/// rather than clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px
|
||||
/// on a side before anything could fail.
|
||||
pub const MAX_TRANSFORM_PIXELS: u64 = 1 << 30;
|
||||
/// The memory (bytes) a single Free Transform warp's result may take: Preferences › Performance ›
|
||||
/// Memory Usage, which [`Session::apply_prefs`] sets, rather than a fixed 2³⁰ px, so a stitched
|
||||
/// layer of a few gigapixels transforms on a machine that has the memory for it (#7807). Only the
|
||||
/// size of the result counts, not where it lands, so moving a layer far off the canvas stays
|
||||
/// allowed as far as the warp reaches ([`WARP_REACH`]). A scale past it is refused rather than
|
||||
/// clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px on a side
|
||||
/// before anything could fail.
|
||||
static BUDGET: AtomicU64 = AtomicU64::new((crate::prefs::DEFAULT_MEMORY_USAGE_MB as u64) << 20);
|
||||
|
||||
/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one.
|
||||
#[cfg(not(test))]
|
||||
const BUDGET: u64 = MAX_TRANSFORM_PIXELS;
|
||||
#[cfg(test)]
|
||||
const BUDGET: u64 = 1 << 20;
|
||||
|
||||
/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget,
|
||||
/// before anything is built. `what` names the surface in the message.
|
||||
pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> {
|
||||
check_budget_with_limit(src, h, what, BUDGET)
|
||||
/// Set the memory (bytes) one transform's result may take (see [`budget`]).
|
||||
pub fn set_budget(bytes: u64) {
|
||||
BUDGET.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> {
|
||||
/// The memory (bytes) one transform's result may take: Memory Usage.
|
||||
pub fn budget() -> u64 {
|
||||
BUDGET.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What [`check_budget`] holds a result to: [`budget`].
|
||||
#[cfg(not(test))]
|
||||
fn active_budget() -> u64 {
|
||||
budget()
|
||||
}
|
||||
|
||||
/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one:
|
||||
/// 2²⁰ px of the widest format (32-bit CMYK with alpha, 20 MB), so a result within the old 2²⁰ px
|
||||
/// test limit still fits at any depth.
|
||||
#[cfg(test)]
|
||||
fn active_budget() -> u64 {
|
||||
(1 << 20) * PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true).bytes_per_pixel() as u64
|
||||
}
|
||||
|
||||
/// Refuse a warp of the content inside `src` (pixels of `format`) by `h` whose result would take
|
||||
/// more memory than the budget, or reach past where the warp places pixels (it would be clipped),
|
||||
/// before anything is built. `what` names the surface in the message, which names the setting or
|
||||
/// the reach rather than a shortage: nothing has been allocated yet.
|
||||
pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str, format: PixelFormat) -> Result<()> {
|
||||
check_budget_with_limit(src, h, what, format, active_budget())
|
||||
}
|
||||
|
||||
/// [`check_budget`] against `budget` bytes (the tests price the production budget with it).
|
||||
fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, format: PixelFormat, budget: u64) -> Result<()> {
|
||||
let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) };
|
||||
if w.saturating_mul(ht) > budget {
|
||||
let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {budget} px limit");
|
||||
return Err(crate::allocation::error("transforming pixels", &detail));
|
||||
// The warp writes each tile into the result as it goes, so the result is what it holds; it
|
||||
// adds alpha to a format without it.
|
||||
let bpp = PixelFormat::new(format.mode, format.sample, true).bytes_per_pixel() as u64;
|
||||
let bytes = w.saturating_mul(ht).saturating_mul(bpp);
|
||||
if bytes > budget {
|
||||
let mb = |b: u64| b.div_ceil(1 << 20);
|
||||
return Err(EngineError::Other(format!(
|
||||
"too large to transform: the transformed {what} would be {w} x {ht} px ({} MB), more than the {} MB Memory Usage allows (Preferences › Performance); the document was not changed",
|
||||
mb(bytes),
|
||||
budget >> 20
|
||||
)));
|
||||
}
|
||||
if warp_output_clipped(src, h) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"too large to transform: the transformed {what} would reach past ±{WARP_REACH} px, the farthest a transform places pixels; the document was not changed"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -303,10 +342,10 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
|
||||
LayerContent::Text(_) | LayerContent::Shape(_) => {}
|
||||
LayerContent::Smart(sm) => {
|
||||
if let Some(c) = &sm.cache {
|
||||
check_budget(c.content_bounds(), h, "smart object")?;
|
||||
check_budget(c.content_bounds(), h, "smart object", c.format())?;
|
||||
}
|
||||
if let Some(m) = &sm.filter_mask {
|
||||
check_budget(m.surface.content_bounds(), h, "smart filter mask")?;
|
||||
check_budget(m.surface.content_bounds(), h, "smart filter mask", m.surface.format())?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -316,7 +355,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
|
||||
Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()),
|
||||
None => surf.content_bounds(),
|
||||
};
|
||||
check_budget(src, h, &format!("layer \"{}\"", l.name))?;
|
||||
check_budget(src, h, &format!("layer \"{}\"", l.name), surf.format())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,7 +367,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
|
||||
Some(sel) => sel.content_bounds(),
|
||||
None => m.surface.content_bounds(),
|
||||
};
|
||||
check_budget(src, h, "layer mask")?;
|
||||
check_budget(src, h, "layer mask", m.surface.format())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -336,7 +375,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
|
||||
|
||||
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
|
||||
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
|
||||
/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`].
|
||||
/// Refused, with `l` untouched, when a result would take more than [`budget`].
|
||||
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
|
||||
transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE)
|
||||
}
|
||||
@@ -514,13 +553,124 @@ pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) {
|
||||
}
|
||||
|
||||
/// Normal-blend `top` over `dst` (both straight alpha, same format).
|
||||
/// Works tile by tile in parallel on `top`'s allocated tiles: fully opaque tiles share their
|
||||
/// `Arc<Tile>` directly with `dst`, and opaque/transparent pixels on border tiles copy encoded
|
||||
/// bytes without a whole-region `f32` round trip.
|
||||
pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) {
|
||||
use photocraft_color::{SampleType, read_sample};
|
||||
use photocraft_geom::TileCoord;
|
||||
use photocraft_raster::Tile;
|
||||
use rayon::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
let fmt = dst.format();
|
||||
let n = fmt.channels();
|
||||
if n == 0 || !fmt.alpha || top.format() != fmt {
|
||||
return;
|
||||
}
|
||||
let a = n - 1;
|
||||
if top.default_pixel().get(a).is_none_or(|&ta| ta <= 0.0) {
|
||||
let top_tiles: Vec<(TileCoord, Arc<Tile>)> = top.tiles().map(|(&tc, t)| (tc, t.clone())).collect();
|
||||
if top_tiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bpp = fmt.bytes_per_pixel();
|
||||
let sb = fmt.sample.bytes();
|
||||
let alpha_off = bpp - sb;
|
||||
let dst_empty_default = dst.default_bytes().iter().all(|&b| b == 0);
|
||||
let dst_ref = &*dst;
|
||||
let edits: Vec<(TileCoord, Arc<Tile>)> = top_tiles
|
||||
.into_par_iter()
|
||||
.filter_map(|(tc, top_tile)| {
|
||||
let tb = top_tile.bytes();
|
||||
let all_opaque = match fmt.sample {
|
||||
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&u8::MAX)),
|
||||
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[u8::MAX, u8::MAX])),
|
||||
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0),
|
||||
};
|
||||
if all_opaque {
|
||||
return Some((tc, top_tile));
|
||||
}
|
||||
let all_transparent = match fmt.sample {
|
||||
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&0)),
|
||||
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[0, 0])),
|
||||
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) <= 0.0),
|
||||
};
|
||||
if all_transparent {
|
||||
return None;
|
||||
}
|
||||
if dst_ref.tile(tc).is_none() && dst_empty_default {
|
||||
let clean_zero = match fmt.sample {
|
||||
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) != Some(&0) || px.iter().all(|&b| b == 0)),
|
||||
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) != Some(&[0, 0]) || px.iter().all(|&b| b == 0)),
|
||||
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) > 0.0 || px.iter().all(|&b| b == 0)),
|
||||
};
|
||||
if clean_zero {
|
||||
return Some((tc, top_tile));
|
||||
}
|
||||
}
|
||||
let mut out_tile = match dst_ref.tile(tc) {
|
||||
Some(dt) => (**dt).clone(),
|
||||
None => dst_ref.try_default_tile().ok()?,
|
||||
};
|
||||
let mut tp = [0.0f32; 8];
|
||||
let mut dp = [0.0f32; 8];
|
||||
for (out_px, top_px) in out_tile.bytes_mut().chunks_exact_mut(bpp).zip(tb.chunks_exact(bpp)) {
|
||||
match fmt.sample {
|
||||
SampleType::U8 => {
|
||||
let ta_b = top_px[alpha_off];
|
||||
if ta_b == 0 {
|
||||
continue;
|
||||
}
|
||||
if ta_b == u8::MAX {
|
||||
out_px.copy_from_slice(top_px);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
SampleType::U16 => {
|
||||
if top_px[alpha_off..bpp] == [0, 0] {
|
||||
continue;
|
||||
}
|
||||
if top_px[alpha_off..bpp] == [u8::MAX, u8::MAX] {
|
||||
out_px.copy_from_slice(top_px);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
SampleType::F32 => {
|
||||
let ta = read_sample(top_px, SampleType::F32, a);
|
||||
if ta <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if ta == 1.0 {
|
||||
out_px.copy_from_slice(top_px);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..n {
|
||||
tp[c] = read_sample(top_px, fmt.sample, c);
|
||||
dp[c] = read_sample(out_px, fmt.sample, c);
|
||||
}
|
||||
let (ta, da) = (tp[a], dp[a]);
|
||||
let oa = ta + da * (1.0 - ta);
|
||||
if oa > 0.0 {
|
||||
for c in 0..a {
|
||||
dp[c] = (tp[c] * ta + dp[c] * da * (1.0 - ta)) / oa;
|
||||
}
|
||||
}
|
||||
dp[a] = oa;
|
||||
photocraft_raster::encode_pixel(&fmt, &dp[..n], out_px);
|
||||
}
|
||||
Some((tc, Arc::new(out_tile)))
|
||||
})
|
||||
.collect();
|
||||
dst.put_tiles(edits);
|
||||
return;
|
||||
}
|
||||
let b = top.content_bounds();
|
||||
if b.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = dst.format().channels();
|
||||
let a = n - 1;
|
||||
let t = top.read_region(b);
|
||||
let mut d = dst.read_region(b);
|
||||
for (dp, tp) in d.chunks_exact_mut(n).zip(t.chunks_exact(n)) {
|
||||
@@ -664,14 +814,14 @@ fn transform_doc(
|
||||
let sel = doc.selection.clone();
|
||||
// The selection outline moves too: price it before anything is built.
|
||||
if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) {
|
||||
check_budget(sel.content_bounds(), h, "selection")?;
|
||||
check_budget(sel.content_bounds(), h, "selection", sel.format())?;
|
||||
}
|
||||
if lone {
|
||||
// A targeted unlinked mask, alpha channel or Quick Mask transforms by itself.
|
||||
if !h.is_identity() {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds);
|
||||
check_budget(src, h, "mask or channel")?;
|
||||
check_budget(src, h, "mask or channel", surf.format())?;
|
||||
*surf = match &sel {
|
||||
Some(sel) => warp_gray_selected(surf, sel, h, interp)?,
|
||||
None => warp_gray(surf, h, interp)?,
|
||||
@@ -737,29 +887,78 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// The content bounds of the active layer's pixels.
|
||||
fn active_bounds(s: &Session) -> Rect {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
// ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ----
|
||||
// ---- Memory budget (#3773, #7807). Tests run with a 20 MB budget (2²⁰ px of 32-bit CMYK with
|
||||
// alpha), so nothing here comes near the real one. ----
|
||||
|
||||
/// Memory Usage's default, in bytes.
|
||||
const DEFAULT_BUDGET: u64 = (crate::prefs::DEFAULT_MEMORY_USAGE_MB as u64) << 20;
|
||||
|
||||
/// Scales of 10⁷ and past are refused by the real budget too, whatever the axis or sign.
|
||||
#[test]
|
||||
fn huge_transform_scales_exceed_the_production_budget_before_clipping() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
for scale in [1e7, 1e28, 1e306] {
|
||||
for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] {
|
||||
let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]);
|
||||
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused");
|
||||
assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA8, DEFAULT_BUDGET).is_err(), "{sx} x {sy} must be refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 240000 px wide stretch, placed far from the canvas but within the warp's reach, fits.
|
||||
#[test]
|
||||
fn an_ordinary_transform_remains_within_the_production_budget() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
let h = Homography([10_000.0, 0.0, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]);
|
||||
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok());
|
||||
let h = Homography([10_000.0, 0.0, 800_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]);
|
||||
assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA8, DEFAULT_BUDGET).is_ok());
|
||||
}
|
||||
|
||||
/// The warp places pixels only within ±2²⁰ px ([`WARP_REACH`]): a result that fits Memory
|
||||
/// Usage but would reach past it, by its size or by where it lands, is refused, not clipped.
|
||||
#[test]
|
||||
fn a_result_past_the_warps_reach_is_refused_not_clipped() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
let check = |h: [f64; 9]| check_budget_with_limit(src, &Homography(h), "layer", PixelFormat::RGBA8, DEFAULT_BUDGET);
|
||||
// 2000016 x 18 px, 138 MB of 8-bit RGBA: from x = 0 it would be cut at 2²⁰; centred it fits.
|
||||
let err = check([83_334.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).unwrap_err().to_string();
|
||||
assert!(err.contains("would reach past ±1048576 px") && err.ends_with("the document was not changed"), "{err}");
|
||||
assert!(check([83_334.0, 0.0, -1_000_000.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_ok());
|
||||
// A small layer moved past the reach, either way on either axis.
|
||||
for (dx, dy) in [(1_100_000.0, 0.0), (-1_100_000.0, 0.0), (0.0, 1_100_000.0), (0.0, -1_100_000.0)] {
|
||||
assert!(check([1.0, 0.0, dx, 0.0, 1.0, dy, 0.0, 0.0, 1.0]).is_err(), "{dx}, {dy}");
|
||||
}
|
||||
// Through the command: nothing moves, nothing is lost.
|
||||
let mut s = session();
|
||||
let st = s.active().unwrap();
|
||||
let (before, steps) = (st.doc.clone(), st.history.past_len());
|
||||
let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 2_000_000, 0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("the transformed layer \"Layer 1\" would reach past ±1048576 px"), "{err}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before && st.history.past_len() == steps, "the document is untouched");
|
||||
}
|
||||
|
||||
/// #7807: the stitched layer's 90 % and 15°, a 37708 x 35259 px result that the old 2³⁰ px limit
|
||||
/// refused, fits the default Memory Usage at 8 bits (5072 MB); at 16 bits it needs Memory Usage
|
||||
/// raised, and the message says so.
|
||||
#[test]
|
||||
fn a_gigapixel_layer_transforms_within_memory_usage() {
|
||||
let src = Rect::new(0, 0, 35_020, 31_171);
|
||||
let r = [0.0, 0.0, 35_020.0, 31_171.0];
|
||||
let h = relative_matrix(&json!({"scaleX": 90, "scaleY": 90, "rotate": 15}), r).unwrap().unwrap();
|
||||
assert_eq!(warp_output_size(src, &h), Some((37_708, 35_259)), "the report's result, past the old limit");
|
||||
assert!(check_budget_with_limit(src, &h, "layer \"Layer 1\"", PixelFormat::RGBA8, DEFAULT_BUDGET).is_ok());
|
||||
let err = check_budget_with_limit(src, &h, "layer \"Layer 1\"", PixelFormat::RGBA16, DEFAULT_BUDGET).unwrap_err().to_string();
|
||||
assert!(err.contains("(10144 MB), more than the 8192 MB Memory Usage allows (Preferences › Performance)"), "{err}");
|
||||
assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA16, 2 * DEFAULT_BUDGET).is_ok());
|
||||
// A format without alpha is priced as the warp makes it, with alpha.
|
||||
let rgb8 = PixelFormat::new(PixelFormat::RGBA8.mode, PixelFormat::RGBA8.sample, false);
|
||||
assert!(check_budget_with_limit(src, &h, "layer", rgb8, 5071 << 20).is_err());
|
||||
}
|
||||
|
||||
/// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10.
|
||||
@@ -780,15 +979,22 @@ mod tests {
|
||||
json!({"matrix": [k, 0, 0, 1, -10.0 * k, 0], "interpolation": "nearest"})
|
||||
}
|
||||
|
||||
/// A stretch past the budget is refused with the limit named, and the document, its revision
|
||||
/// and History stay as they were; within the budget the same stretch applies unclipped.
|
||||
#[test]
|
||||
fn a_scale_past_the_pixel_budget_is_refused_and_changes_nothing() {
|
||||
let mut s = column();
|
||||
let st = s.active().unwrap();
|
||||
let (before, revision, steps) = (st.doc.clone(), st.revision, st.history.past_len());
|
||||
// 200000 x 10 px of output: twice the test budget.
|
||||
let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string();
|
||||
assert!(err.starts_with("not enough memory for transforming pixels"), "{err}");
|
||||
assert!(err.contains("would be 200002 x 10 px") && err.ends_with("the document was not changed"), "{err}");
|
||||
// 1000000 x 10 px of 8-bit RGBA output: about twice the test budget.
|
||||
let err = s.execute("edit.transform", stretch(1e6)).unwrap_err().to_string();
|
||||
assert!(err.starts_with("too large to transform: the transformed layer \"Layer 1\""), "{err}");
|
||||
assert!(
|
||||
err.contains("would be 1000002 x 10 px (39 MB), more than the 20 MB Memory Usage allows") && err.ends_with("the document was not changed"),
|
||||
"{err}"
|
||||
);
|
||||
// Nothing was allocated, so nothing ran short (#7807).
|
||||
assert!(!err.contains("not enough memory"), "{err}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before, "the document is untouched");
|
||||
assert_eq!((st.revision, st.history.past_len()), (revision, steps));
|
||||
@@ -806,7 +1012,7 @@ mod tests {
|
||||
let dot = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("paint", |doc, _| {
|
||||
doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 11, 13), &[0.0, 1.0, 0.0, 1.0]);
|
||||
// Widen the column to 8 px (x 2..10), so it alone needs 400002 x 10 px.
|
||||
// Widen the column to 8 px (x 2..10), so it alone needs 800002 x 10 px (31 MB).
|
||||
doc.layer_mut(wide).unwrap().surface_mut().unwrap().fill_rect(Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
@@ -817,7 +1023,7 @@ mod tests {
|
||||
let st = s.active().unwrap();
|
||||
assert!(matches!(&st.doc.layer(group).unwrap().content, LayerContent::Group(g) if g.children.len() == 2));
|
||||
let (before, revision) = (st.doc.clone(), st.revision);
|
||||
let k = 5e4;
|
||||
let k = 1e5;
|
||||
assert!(s.execute("edit.transform", json!({"layer": group.0, "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err());
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before && st.revision == revision, "nothing changed");
|
||||
@@ -829,6 +1035,8 @@ mod tests {
|
||||
assert!(&l == st.doc.layer(group).unwrap(), "no child was transformed");
|
||||
}
|
||||
|
||||
/// Only the result's size is priced: a small layer moved 900000 px away (within the warp's
|
||||
/// reach) and back keeps every pixel.
|
||||
#[test]
|
||||
fn moving_a_small_layer_far_off_the_canvas_is_within_budget() {
|
||||
let mut s = session();
|
||||
@@ -838,28 +1046,31 @@ mod tests {
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "and back, every pixel kept");
|
||||
}
|
||||
|
||||
/// Each priced at its own format, as the warp makes it: an 8-bit outline, mask or channel with
|
||||
/// alpha is 2 bytes a pixel, so a canvas-high column of it stretched 10⁶ times (34 MB) is over
|
||||
/// the test budget.
|
||||
#[test]
|
||||
fn selections_masks_and_channels_have_the_same_budget() {
|
||||
// The selection outline of a layer transform.
|
||||
let mut s = column();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 16})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string();
|
||||
let err = s.execute("edit.transform", stretch(1e6)).unwrap_err().to_string();
|
||||
assert!(err.contains("transformed selection would be"), "{err}");
|
||||
// Transform Selection.
|
||||
let err = s.execute("select.transformSelection", stretch(2e5)).unwrap_err().to_string();
|
||||
let err = s.execute("select.transformSelection", stretch(1e6)).unwrap_err().to_string();
|
||||
assert!(err.contains("transformed selection would be"), "{err}");
|
||||
assert!(s.active().unwrap().doc == before);
|
||||
// A targeted alpha channel.
|
||||
let mut s = column();
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
s.edit("paint channel", |doc, _| {
|
||||
doc.channels[0].surface.fill_rect(Rect::new(10, 2, 11, 10), &[1.0]);
|
||||
doc.channels[0].surface.fill_rect(Rect::new(10, 0, 11, 16), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let mut p = stretch(2e5);
|
||||
let mut p = stretch(1e6);
|
||||
p["target"] = json!({"channel": 0});
|
||||
let err = s.execute("edit.transform", p).unwrap_err().to_string();
|
||||
assert!(err.contains("transformed mask or channel would be"), "{err}");
|
||||
@@ -1539,4 +1750,32 @@ mod tests {
|
||||
assert_eq!((x0.round(), y0.round(), x1.round(), y1.round()), (-10.0, 30.0, 110.0, 70.0));
|
||||
assert_eq!((sh.density, sh.feather), (1.0, 20.0), "the transform keeps the soft settings");
|
||||
}
|
||||
|
||||
/// A stroked soft shape's frame is the hard shape's rendered frame for every stroke alignment:
|
||||
/// a centred stroke reaches half its width past the path, not its full width; both stop at
|
||||
/// the canvas edge.
|
||||
#[test]
|
||||
fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() {
|
||||
for (align, reach, x) in [("center", 5, 50), ("inside", 0, 50), ("outside", 10, 50), ("center", 5, -20)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let stroke = json!({"width": 10, "color": "#ff0000", "align": align, "join": "round"});
|
||||
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [x, 50, 60, 40], "fill": "#000000", "stroke": stroke})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let id = photocraft_doc::LayerId(id);
|
||||
let boxes = |s: &Session| {
|
||||
let st = s.active().unwrap();
|
||||
let l = st.doc.layer(id).unwrap();
|
||||
(transform_bounds(&st.doc, l), transform_frame(&st.doc, l), crate::snap::layer_rect(&st.doc, id))
|
||||
};
|
||||
let hard = boxes(&s);
|
||||
// Hand-computed: the path is x..x + 60 x 50..90, the stroke reaches `reach` px past it.
|
||||
assert_eq!(hard.0, Rect::new((x - reach).max(0), 50 - reach, x + 60 + reach, 90 + reach), "{align} at {x}: the hard shape's frame");
|
||||
s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 8})).unwrap();
|
||||
assert_eq!(boxes(&s), hard, "{align} at {x}: Density and Feather leave the frame");
|
||||
s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 8})).unwrap();
|
||||
assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,8 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> {
|
||||
}
|
||||
|
||||
/// Validates text metrics (size, tracking, leading, baseline shift, scales) that can otherwise
|
||||
/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold.
|
||||
/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold,
|
||||
/// and the superscript/subscript `baseline`, so a typo is an error rather than a reset to Normal.
|
||||
pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
|
||||
if let Some(v) = p.get("size") {
|
||||
match v.as_f64() {
|
||||
@@ -138,6 +139,11 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
|
||||
_ => return Err(format!("baselineShift must be between -{max} and {max} pt")),
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("baseline").filter(|v| !v.is_null())
|
||||
&& !matches!(v.as_str(), Some("normal" | "superscript" | "subscript"))
|
||||
{
|
||||
return Err("baseline must be \"normal\", \"superscript\" or \"subscript\"".into());
|
||||
}
|
||||
for key in ["horizontalScale", "verticalScale"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
match v.as_f64() {
|
||||
@@ -365,12 +371,17 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
hit(true);
|
||||
}
|
||||
if let Some(v) = p.get("baseline").and_then(Value::as_str) {
|
||||
s.baseline = match v {
|
||||
"superscript" => FontBaseline::Superscript,
|
||||
"subscript" => FontBaseline::Subscript,
|
||||
_ => FontBaseline::Normal,
|
||||
let baseline = match v {
|
||||
"superscript" => Some(FontBaseline::Superscript),
|
||||
"subscript" => Some(FontBaseline::Subscript),
|
||||
"normal" => Some(FontBaseline::Normal),
|
||||
_ => None,
|
||||
};
|
||||
hit(true);
|
||||
// An unknown value keeps the style (the commands reject it in `check_size_tracking`).
|
||||
if let Some(b) = baseline {
|
||||
s.baseline = b;
|
||||
hit(true);
|
||||
}
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("features") {
|
||||
for (tag, v) in m {
|
||||
@@ -1617,6 +1628,23 @@ mod tests {
|
||||
assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]);
|
||||
}
|
||||
|
||||
/// A misspelt `baseline` is an error and keeps superscript, instead of resetting it to Normal.
|
||||
#[test]
|
||||
fn unknown_baseline_is_rejected_and_keeps_the_style() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "x2", "size": 30})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("type.setStyle", json!({"layer": id, "range": [1, 2], "baseline": "superscript"})).unwrap();
|
||||
let before = text_layer(&s, id).runs;
|
||||
for b in [json!("superscipt"), json!("Superscript"), json!(1)] {
|
||||
assert!(matches!(s.execute("type.setStyle", json!({"layer": id, "baseline": b})), Err(EngineError::BadParams { .. })), "{b}");
|
||||
assert!(s.execute("type.edit", json!({"layer": id, "baseline": b})).is_err(), "{b}");
|
||||
assert!(s.execute("type.create", json!({"x": 0, "y": 10, "text": "a", "baseline": b})).is_err(), "{b}");
|
||||
}
|
||||
assert_eq!(text_layer(&s, id).runs, before);
|
||||
s.execute("type.setStyle", json!({"layer": id, "baseline": "normal"})).unwrap();
|
||||
assert!(text_layer(&s, id).char_runs().iter().all(|r| r.style.baseline == FontBaseline::Normal));
|
||||
}
|
||||
|
||||
fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> {
|
||||
let t = text_layer(s, id);
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -527,16 +527,39 @@ fn style_font_missing(fonts: &mut photocraft_text::FontDb, style: &photocraft_do
|
||||
true
|
||||
}
|
||||
|
||||
/// Font families used by type layers that the font database cannot supply.
|
||||
/// The document's type layers, inside groups too.
|
||||
fn type_layers(doc: &Document) -> Vec<&TextLayer> {
|
||||
doc.walk()
|
||||
.into_iter()
|
||||
.filter_map(|(_, _, l)| match &l.content {
|
||||
LayerContent::Text(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `doc` has a type layer. Only then does checking its fonts need the shared text engine,
|
||||
/// whose first use waits for the installed-font scan started at launch: minutes on some machines
|
||||
/// (#7332).
|
||||
pub fn has_type_layers(doc: &Document) -> bool {
|
||||
doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Text(_)))
|
||||
}
|
||||
|
||||
/// Font families used by type layers that the font database cannot supply. A document without
|
||||
/// type returns at once, without touching the shared text engine: the shell asks this on the UI
|
||||
/// thread for every file it opens, and waiting there for the font scan froze the window on the
|
||||
/// first open after launch (#7332).
|
||||
pub fn missing_fonts(doc: &Document) -> Vec<String> {
|
||||
let layers = type_layers(doc);
|
||||
if layers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (_, _, l) in doc.walk() {
|
||||
if let LayerContent::Text(t) = &l.content {
|
||||
for r in t.char_runs() {
|
||||
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
|
||||
out.push(r.style.font_family);
|
||||
}
|
||||
for t in layers {
|
||||
for r in t.char_runs() {
|
||||
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
|
||||
out.push(r.style.font_family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user