mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 17:12:52 -06:00
Compare commits
520
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e6a461fe | ||
|
|
31a821436f | ||
|
|
8ec4f6cabb | ||
|
|
5a668bf02e | ||
|
|
3e178d8f5a | ||
|
|
f68150ee10 | ||
|
|
0d45a64db2 | ||
|
|
e832a373f5 | ||
|
|
6c63345615 | ||
|
|
1f2c75a288 | ||
|
|
fd4c533425 | ||
|
|
d4d99c7627 | ||
|
|
9fce04f0b4 | ||
|
|
ffd77c7db0 | ||
|
|
1f6587b60d | ||
|
|
d09f4c1284 | ||
|
|
37a6aed679 | ||
|
|
02ea528e94 | ||
|
|
bcc4fc97e6 | ||
|
|
f4fcac9f2d | ||
|
|
babc950f81 | ||
|
|
468394b3c8 | ||
|
|
5eb0b7d2c0 | ||
|
|
01220df579 | ||
|
|
b0b87c5cad | ||
|
|
befa4cd69f | ||
|
|
cc00a17dfe | ||
|
|
90058395b7 | ||
|
|
ee39ff6781 | ||
|
|
bbd579813f | ||
|
|
eaf8d1ce3f | ||
|
|
f2f0c4373d | ||
|
|
14cc73101b | ||
|
|
650c4f9497 | ||
|
|
9f952550b2 | ||
|
|
5fb694d991 | ||
|
|
7f742c9d4f | ||
|
|
b5033b6213 | ||
|
|
b6c1d87cf0 | ||
|
|
c37b150950 | ||
|
|
f3d8ba85a6 | ||
|
|
e690829543 | ||
|
|
c0468b59cd | ||
|
|
5d27461fa6 | ||
|
|
773133ec42 | ||
|
|
8ec4f29059 | ||
|
|
87a10fc8e2 | ||
|
|
3fb6c43136 | ||
|
|
82573331a8 | ||
|
|
d990678b32 | ||
|
|
cd19752bb5 | ||
|
|
2ed2ce18d8 | ||
|
|
c5ac1dd44b | ||
|
|
84ac51a439 | ||
|
|
c3e9bd07e5 | ||
|
|
a3fbf02952 | ||
|
|
92bf7fc67d | ||
|
|
0ed4b394ac | ||
|
|
e747bff425 | ||
|
|
f7a7fd7f5f | ||
|
|
800ffeaa59 | ||
|
|
d080a23dc0 | ||
|
|
243c1a269f | ||
|
|
06a1ca4672 | ||
|
|
40728b5362 | ||
|
|
d2504e5f8b | ||
|
|
d92874f541 | ||
|
|
6a34f13808 | ||
|
|
6058fc7882 | ||
|
|
04b0958bee | ||
|
|
acf054852c | ||
|
|
861879f8ee | ||
|
|
e626625d40 | ||
|
|
4a0f48a04c | ||
|
|
8f6e1b420a | ||
|
|
c0db6ebeba | ||
|
|
a667e13a03 | ||
|
|
3bf35c801f | ||
|
|
47eb6732fa | ||
|
|
8a2eccc863 | ||
|
|
fb05eaa30f | ||
|
|
231a52438c | ||
|
|
a00b7e8a51 | ||
|
|
9678ed7d7f | ||
|
|
1de7b44a8d | ||
|
|
5a235590dd | ||
|
|
a96dcdbe8f | ||
|
|
0e3f4cd353 | ||
|
|
72c8621358 | ||
|
|
cccc9064ca | ||
|
|
1de0101ced | ||
|
|
f5c2aca644 | ||
|
|
4408ff6153 | ||
|
|
ad97533aec | ||
|
|
727b682d17 | ||
|
|
8770fb90c3 | ||
|
|
9a716be59e | ||
|
|
479c3cc5ea | ||
|
|
4ce2944630 | ||
|
|
255df314da | ||
|
|
b7482dd392 | ||
|
|
e83f964077 | ||
|
|
eaeb06c655 | ||
|
|
ede7a7ead7 | ||
|
|
68dda6f7d8 | ||
|
|
9aef8cc5ab | ||
|
|
6113f4e1a2 | ||
|
|
77e3a444b8 | ||
|
|
4c1d77ddce | ||
|
|
09bbfc7217 | ||
|
|
443f503407 | ||
|
|
e812da58df | ||
|
|
ee4bdbcd7d | ||
|
|
adaa4e6fa1 | ||
|
|
446bc528ad | ||
|
|
3c068df628 | ||
|
|
f8165751e5 | ||
|
|
7190c85860 | ||
|
|
ddd39249ba | ||
|
|
b4c1905d78 | ||
|
|
2c11865b2d | ||
|
|
b92a7ddbd5 | ||
|
|
0768e95591 | ||
|
|
82adf7326e | ||
|
|
ef6fd29928 | ||
|
|
a3449185ac | ||
|
|
c4221b1a7c | ||
|
|
46f23229ab | ||
|
|
ae08bbe62d | ||
|
|
7b46958fa2 | ||
|
|
c7b8a11895 | ||
|
|
81eff036d2 | ||
|
|
bcbdb76bfd | ||
|
|
3dc66ac1a5 | ||
|
|
1b5d860dfc | ||
|
|
3e8a9193bc | ||
|
|
cee1d015ec | ||
|
|
dc1fc8d83b | ||
|
|
c66fee15f2 | ||
|
|
b7bec00386 | ||
|
|
087c11bb42 | ||
|
|
4d2c1a3bac | ||
|
|
28756fc755 | ||
|
|
c3d1df6d28 | ||
|
|
2275e56319 | ||
|
|
c8d1a7c7df | ||
|
|
8713686274 | ||
|
|
e6f9e912f7 | ||
|
|
6208d3eaeb | ||
|
|
58abe9ddb7 | ||
|
|
34cfc5b20b | ||
|
|
e279461ca7 | ||
|
|
63934efc69 | ||
|
|
f61b7a34dd | ||
|
|
b8cfcf20a2 | ||
|
|
1841ab75da | ||
|
|
c1b0cf72df | ||
|
|
ba29b61f8c | ||
|
|
11b271b9ae | ||
|
|
352fec7725 | ||
|
|
359e46f042 | ||
|
|
ecad13d1cc | ||
|
|
9554cc7eb8 | ||
|
|
7554d647cd | ||
|
|
e713bccefd | ||
|
|
2153f8ac4d | ||
|
|
42b0e1de8f | ||
|
|
9749ec992f | ||
|
|
f08e5f8691 | ||
|
|
5b0871e5f3 | ||
|
|
0f5d01a461 | ||
|
|
234329a8d2 | ||
|
|
71c5da07eb | ||
|
|
a3d14bc8b0 | ||
|
|
2feb7dc2b8 | ||
|
|
5187e01856 | ||
|
|
6030fb4f8c | ||
|
|
b3c36e816a | ||
|
|
95a55b0ea7 | ||
|
|
5e85f73c5e | ||
|
|
5191af8ebb | ||
|
|
8d278d71cb | ||
|
|
9cb51393b3 | ||
|
|
2d48221edb | ||
|
|
b3ccea8577 | ||
|
|
e96ae0b6f5 | ||
|
|
6fa022d50f | ||
|
|
62b06a0a91 | ||
|
|
c389f62b33 | ||
|
|
6d02e90177 | ||
|
|
52d81a9468 | ||
|
|
81ab1bce0d | ||
|
|
3465ed9d47 | ||
|
|
f29b7e7eed | ||
|
|
8075b7ecd8 | ||
|
|
a37fa65852 | ||
|
|
21bbb4127d | ||
|
|
36eaa7e051 | ||
|
|
932ec7bef6 | ||
|
|
34af55bd6b | ||
|
|
90c8f65f5e | ||
|
|
b082f12f70 | ||
|
|
fe54c0eb3c | ||
|
|
3a4e30e10e | ||
|
|
505a6f28ac | ||
|
|
0da9cac9dc | ||
|
|
97db282daf | ||
|
|
99bb0f0a62 | ||
|
|
32ef926c46 | ||
|
|
4c1916466b | ||
|
|
afe4c324a7 | ||
|
|
079ea961dd | ||
|
|
f7b76e7894 | ||
|
|
35a9b812fd | ||
|
|
8333c371e1 | ||
|
|
1bdd63dabe | ||
|
|
6fbfea12a6 | ||
|
|
62e020c1c1 | ||
|
|
8cc25169ab | ||
|
|
9e5afc2c90 | ||
|
|
0e556d4e84 | ||
|
|
cfd30ce4b2 | ||
|
|
23c19d8e0c | ||
|
|
14eef3aceb | ||
|
|
33b6e11726 | ||
|
|
6d2fd18923 | ||
|
|
d8c1880331 | ||
|
|
8c2560f31c | ||
|
|
2ba45cdd4c | ||
|
|
d9f90c517d | ||
|
|
2ac978ce54 | ||
|
|
f893f6af7d | ||
|
|
6d699b484f | ||
|
|
22d9796786 | ||
|
|
83ad5d287c | ||
|
|
9388884d20 | ||
|
|
e0c64599cc | ||
|
|
b54edd61b0 | ||
|
|
d0f0d14c99 | ||
|
|
c2f3397483 | ||
|
|
d1487e6916 | ||
|
|
29f0f7772c | ||
|
|
d97a596780 | ||
|
|
63b181170c | ||
|
|
352086304e | ||
|
|
6a9f18af92 | ||
|
|
fd102fb47b | ||
|
|
edbf542570 | ||
|
|
fca0492725 | ||
|
|
b57ab3fb35 | ||
|
|
6d09d3770f | ||
|
|
6ddd2fb869 | ||
|
|
db245a7029 | ||
|
|
8c6c72e39f | ||
|
|
1a203c4a73 | ||
|
|
7f1ebc2a5c | ||
|
|
8e4d0e6b4c | ||
|
|
3f853db3f6 | ||
|
|
d9749fbcc5 | ||
|
|
0e7cd01381 | ||
|
|
9c52950536 | ||
|
|
c58f19f1b5 | ||
|
|
4de3a4dc8b | ||
|
|
f441ea93a2 | ||
|
|
6a59eb8cef | ||
|
|
e03ff82504 | ||
|
|
e75a72c0cc | ||
|
|
cd5ff01810 | ||
|
|
9c24a63d1e | ||
|
|
5638b0ca09 | ||
|
|
8dfa6f1994 | ||
|
|
ddd7d210ef | ||
|
|
d8c88cea1d | ||
|
|
322be04e7b | ||
|
|
2cad063d1d | ||
|
|
3356bc071c | ||
|
|
0efd6f1b85 | ||
|
|
1ca71474a2 | ||
|
|
f58ec39968 | ||
|
|
d027f9d866 | ||
|
|
7c891de450 | ||
|
|
b5928a182e | ||
|
|
5896859440 | ||
|
|
d989389441 | ||
|
|
31efc67f73 | ||
|
|
8680166081 | ||
|
|
90ce7dcb9b | ||
|
|
9d32bc4da9 | ||
|
|
faf4c0ee52 | ||
|
|
4c83fb9abe | ||
|
|
6a1b8f1e39 | ||
|
|
1e842137b0 | ||
|
|
d6f532bb0d | ||
|
|
952e4db95f | ||
|
|
f4ca20659c | ||
|
|
b5257f9446 | ||
|
|
50b200be7c | ||
|
|
48535be970 | ||
|
|
7b424a463b | ||
|
|
55e5ee56b4 | ||
|
|
f2f4dfabc0 | ||
|
|
964f4759c7 | ||
|
|
110d46daae | ||
|
|
b003dde823 | ||
|
|
8cb3365596 | ||
|
|
88c2ceff5f | ||
|
|
2f4a6d906a | ||
|
|
53e183495b | ||
|
|
8445d98e24 | ||
|
|
2c9edcc394 | ||
|
|
feecee0a30 | ||
|
|
356195ba33 | ||
|
|
b1645b006e | ||
|
|
428c8809ea | ||
|
|
abee4d7522 | ||
|
|
4d9b325d35 | ||
|
|
b9842d0b82 | ||
|
|
cd2e780432 | ||
|
|
20553865cc | ||
|
|
d8961ebc1e | ||
|
|
9ad147b310 | ||
|
|
6fa873dcfc | ||
|
|
ee875abffa | ||
|
|
020e53e7a0 | ||
|
|
65826295cc | ||
|
|
8c8ab9561b | ||
|
|
62ea604394 | ||
|
|
fa0a3e9dee | ||
|
|
1b2e5ea9e0 | ||
|
|
c5994e16db | ||
|
|
5a28760b1e | ||
|
|
8ada601630 | ||
|
|
12001d14e7 | ||
|
|
bbf7a8b8c7 | ||
|
|
96f0ac204e | ||
|
|
6b57773373 | ||
|
|
838d51ed59 | ||
|
|
5b02b1744c | ||
|
|
8b456dce41 | ||
|
|
15bf821535 | ||
|
|
d7c06b53eb | ||
|
|
97b98767f1 | ||
|
|
d5180fbb70 | ||
|
|
9c222be711 | ||
|
|
28371e27d7 | ||
|
|
4bdd5b5bee | ||
|
|
99688fe45a | ||
|
|
ada66524a2 | ||
|
|
e84ba9def1 | ||
|
|
9545ff4d61 | ||
|
|
c8a6363a0f | ||
|
|
462310faed | ||
|
|
d428b4804a | ||
|
|
9ab4ed9581 | ||
|
|
a094f37ed9 | ||
|
|
742e660afd | ||
|
|
8338e21ecf | ||
|
|
7ab59a0cd3 | ||
|
|
a730e66757 | ||
|
|
ac3c3b7b2a | ||
|
|
1368d06eeb | ||
|
|
af61c1cf3a | ||
|
|
099dfa7670 | ||
|
|
d1022cdaa4 | ||
|
|
dcd06db2d3 | ||
|
|
82fb8e7680 | ||
|
|
59e4b272d8 | ||
|
|
62aa7aa437 | ||
|
|
af9b8c1adb | ||
|
|
d6026de7c2 | ||
|
|
d9199598e1 | ||
|
|
a2194407a1 | ||
|
|
b440c2ff40 | ||
|
|
65a3ddc8f0 | ||
|
|
00eb5caad6 | ||
|
|
50feadc965 | ||
|
|
e9a7413c87 | ||
|
|
b48467943b | ||
|
|
0f4665ebe2 | ||
|
|
8528674c57 | ||
|
|
90a3ffe766 | ||
|
|
03025647d6 | ||
|
|
583f2dfb1b | ||
|
|
33a0df30d6 | ||
|
|
36f454b60f | ||
|
|
9ce54ad73f | ||
|
|
2fb85dfa2f | ||
|
|
1a2f8c0b0f | ||
|
|
b7a343d9cf | ||
|
|
f987fe63a7 | ||
|
|
899cd0e0dc | ||
|
|
d741144240 | ||
|
|
27009cd941 | ||
|
|
4bb73d5d34 | ||
|
|
b31a59ad89 | ||
|
|
039395ae4f | ||
|
|
ae30368106 | ||
|
|
0865152e86 | ||
|
|
1956b6b491 | ||
|
|
6396c480b2 | ||
|
|
aef364867d | ||
|
|
78c77ad450 | ||
|
|
939bd12832 | ||
|
|
aaf6c1c38a | ||
|
|
16fe4ebc65 | ||
|
|
430d0a91c0 | ||
|
|
c93e0a0bac | ||
|
|
aa3a1baa01 | ||
|
|
edfee40392 | ||
|
|
580b577cf2 | ||
|
|
230b6485d9 | ||
|
|
3418dec9e4 | ||
|
|
a78d9a9a19 | ||
|
|
560f80abe2 | ||
|
|
2aa0310e27 | ||
|
|
b6fd616977 | ||
|
|
9d23ff97b2 | ||
|
|
71352733d4 | ||
|
|
1bee1857d6 | ||
|
|
13ed8889da | ||
|
|
dbc5e164dd | ||
|
|
51eef9927f | ||
|
|
222ef368f4 | ||
|
|
d318583867 | ||
|
|
1705712a41 | ||
|
|
d4945d0443 | ||
|
|
b58df1095d | ||
|
|
b70a6fda35 | ||
|
|
5d9c3818a5 | ||
|
|
31cf44c2ff | ||
|
|
d20b08e3af | ||
|
|
4504495e2b | ||
|
|
5e9043afa6 | ||
|
|
f1bb876e28 | ||
|
|
5648c4d7ec | ||
|
|
d85e397be4 | ||
|
|
0e78adc675 | ||
|
|
556048436e | ||
|
|
637bbcdf51 | ||
|
|
b6c1fe628c | ||
|
|
ad664b2cdd | ||
|
|
8b2c0f456a | ||
|
|
ea4001a26e | ||
|
|
4268cab027 | ||
|
|
c8a13561b3 | ||
|
|
90f723918b | ||
|
|
5f5f341d27 | ||
|
|
d217820576 | ||
|
|
d6d30bbf20 | ||
|
|
7a9786b355 | ||
|
|
5e2f1993c3 | ||
|
|
24905e7057 | ||
|
|
3c4d6e12de | ||
|
|
7d6445e060 | ||
|
|
b21d71e648 | ||
|
|
ec274ec2fe | ||
|
|
178edac4ac | ||
|
|
d0411d0332 | ||
|
|
f034901719 | ||
|
|
547807c58d | ||
|
|
5ed7844cb9 | ||
|
|
f9b564cd00 | ||
|
|
7d4c49051c | ||
|
|
52d0fcd6c7 | ||
|
|
121441a079 | ||
|
|
abdf5f337b | ||
|
|
95a8f90d1a | ||
|
|
a378c78c25 | ||
|
|
29bd07e1b8 | ||
|
|
6523a49fb7 | ||
|
|
8a730912c2 | ||
|
|
ea72d9d712 | ||
|
|
6a24586412 | ||
|
|
9c985e6a6a | ||
|
|
ab43e4b8f2 | ||
|
|
a7ba879a3c | ||
|
|
d384bbc496 | ||
|
|
98ce1b35be | ||
|
|
2e54272415 | ||
|
|
8145cc1bac | ||
|
|
9341ae0616 | ||
|
|
9e9e13fc2c | ||
|
|
1525dae1bd | ||
|
|
53bdcbafef | ||
|
|
9830b57fbe | ||
|
|
8236a9441b | ||
|
|
61b3108be7 | ||
|
|
c15e260794 | ||
|
|
744c01f66f | ||
|
|
35f240e453 | ||
|
|
8bc0afaff5 | ||
|
|
66e369de26 | ||
|
|
291725c78e | ||
|
|
507fdc3767 | ||
|
|
2d3997d2e3 | ||
|
|
a8f1573fba | ||
|
|
53eda1e8df | ||
|
|
aece097096 | ||
|
|
920925abae | ||
|
|
274b626007 | ||
|
|
afb52aeef3 | ||
|
|
8c4907a626 | ||
|
|
8fd1c1716f | ||
|
|
fb778030c9 | ||
|
|
ef6819b6fd | ||
|
|
63a2e099a6 | ||
|
|
238b34be00 | ||
|
|
ee248bb5cb | ||
|
|
866f349fbf | ||
|
|
9679c6e16f | ||
|
|
2bc30fbd9a | ||
|
|
48c5ff67bf | ||
|
|
78ab8f0626 | ||
|
|
57acc28df3 | ||
|
|
811758f791 | ||
|
|
c3981daeb6 | ||
|
|
300872fb7d | ||
|
|
c0128a0eb8 | ||
|
|
0ce545fcb8 | ||
|
|
6655a1d68d |
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -21,3 +21,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -29,3 +29,5 @@ jobs:
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
install-relative-path: Programs/ArtCraft/DeckCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -6,11 +6,6 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a
|
||||
session, follow its standards, and contribute reusable learnings back there. Never code: repos
|
||||
don't share code.
|
||||
|
||||
These rules bind every AI agent and every human contributor. They come before any task,
|
||||
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
|
||||
file wins.**
|
||||
@@ -69,7 +64,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed
|
||||
or `docs/brand/` has no row.
|
||||
- **`NOTICE`** keeps the notices the licences require.
|
||||
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
repo. DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
`fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
|
||||
`../craft-fonts`).
|
||||
|
||||
@@ -99,7 +94,6 @@ once and tell the user.
|
||||
## 3. Never crash
|
||||
|
||||
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.**
|
||||
Standard: craftrules `standards/never-crash.md`.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
|
||||
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
@@ -160,7 +154,7 @@ to `main`.
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
docs:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
@@ -184,11 +178,13 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) |
|
||||
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -27,7 +27,7 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
|
||||
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
|
||||
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp8.webm`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / libvpx-vp9 / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
|
||||
| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
|
||||
win over anything here.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## Start every session
|
||||
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
|
||||
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
|
||||
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
|
||||
## Habits
|
||||
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
|
||||
@@ -15,3 +18,8 @@ win over anything here.
|
||||
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
|
||||
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
|
||||
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+771
-131
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,8 +59,11 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -78,9 +81,11 @@ arboard = { version = "3.6", default-features = false }
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
self_named_constructors = "allow"
|
||||
large_enum_variant = "allow"
|
||||
@@ -104,7 +109,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -286,7 +286,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+58
-5
@@ -2,10 +2,25 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -40,7 +55,7 @@ Calibration is the same as for the full number (see [docs/target-app-parity.md](
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
@@ -113,11 +128,11 @@ ships 26.
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
image paste on Windows (#1414), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
|
||||
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
|
||||
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, EMF+ records,
|
||||
MicroType Express font subsets and SVG. 36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
@@ -134,26 +149,47 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
|
||||
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
|
||||
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
|
||||
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
|
||||
| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) |
|
||||
| 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) |
|
||||
| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide |
|
||||
| 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) |
|
||||
| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear |
|
||||
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
|
||||
| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits |
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
|
||||
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
|
||||
| 2026-10-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |
|
||||
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
|
||||
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
|
||||
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
|
||||
| 2026-10-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" |
|
||||
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
|
||||
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
|
||||
| 2026-10-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) |
|
||||
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
|
||||
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
|
||||
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
|
||||
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
|
||||
| 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
|
||||
| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
@@ -274,7 +310,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-10 | Progress docs restructured; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
@@ -285,13 +321,28 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
|
||||
| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. |
|
||||
| 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged |
|
||||
| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Ctrl+wheel zoom during the slide show (#1286). Scores and milestone names unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
|
||||
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
|
||||
| 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |
|
||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
|
||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
@@ -299,6 +350,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||
| 2026-10-26 | trivial | Progress log: spaces after symbol-font characters |
|
||||
| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
|
||||
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
|
||||
@@ -370,5 +423,5 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -4,29 +4,24 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect() }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
/// Returns whether this argument is a root flag. Both spellings (`--flag DIR` and
|
||||
/// `--flag=DIR`) may be repeated.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -40,8 +35,7 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -51,17 +45,16 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
fn roots_are_repeated_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -71,8 +64,8 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
@@ -84,7 +77,7 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
@@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
|
||||
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
|
||||
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
|
||||
line endings and an optional UTF-8 BOM at the start of the file are accepted.
|
||||
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
|
||||
File errors name the path; line-reading, JSON and command errors also name the line number.
|
||||
|
||||
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
@@ -127,16 +136,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
|
||||
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
|
||||
app is not confined by them).",
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed. Repeat either
|
||||
flag to grant access to more folders; a root left out grants none of its access. They run the in-process
|
||||
server, so they conflict with --connect (a running app is not confined by them).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
@@ -180,7 +189,10 @@ A presentation is edited by running commands on it:
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
`deckcraft-cli help run` explains how to write a command.
|
||||
|
||||
If an output reader closes early, run, render, render-transition and convert continue; their exit
|
||||
status reflects the work, not output delivery. Stop the process to cancel them.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
+121
-24
@@ -7,23 +7,38 @@ mod automation_args;
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
STDOUT_CLOSED.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
#[cfg(unix)]
|
||||
let mut stdout = {
|
||||
use std::os::fd::AsFd as _;
|
||||
std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.write_all(format!("{args}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
@@ -73,7 +88,7 @@ fn main() -> ExitCode {
|
||||
Some("app") => report(app(rest)),
|
||||
Some("mcp") => report(mcp(rest)),
|
||||
Some("links") => {
|
||||
use deckcraft_engine::links::*;
|
||||
use deckcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, WEBSITE};
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -146,9 +161,13 @@ fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f6
|
||||
f()?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
Ok(summarize_ms(ms))
|
||||
}
|
||||
|
||||
/// Summarize one population of measurements; each maximum must come from that population.
|
||||
fn summarize_ms(mut ms: Vec<f64>) -> (f64, f64) {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
|
||||
Ok((median, ms.last().copied().unwrap_or(0.0)))
|
||||
(ms.get(ms.len() / 2).copied().unwrap_or(0.0), ms.last().copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
|
||||
@@ -208,11 +227,15 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
|
||||
("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
|
||||
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
];
|
||||
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
|
||||
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
|
||||
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
|
||||
let render_all = time_ms(runs, || {
|
||||
for i in 0..slides {
|
||||
deckcraft_engine::render::render_slide(&doc, i, &opts);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
steps.insert(1, ("render all slides (1×)", render_all));
|
||||
let peak = peak_rss_mib();
|
||||
if as_json {
|
||||
let v = json!({
|
||||
@@ -450,6 +473,69 @@ fn output_format(path: &str) -> Result<&'static str, String> {
|
||||
})
|
||||
}
|
||||
|
||||
enum CommandSource {
|
||||
Inline(String),
|
||||
File(String),
|
||||
}
|
||||
|
||||
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
|
||||
line.clear();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok(!line.is_empty());
|
||||
}
|
||||
let newline = available.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.unwrap_or(available.len());
|
||||
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
|
||||
})?;
|
||||
line.extend_from_slice(&available[..count]);
|
||||
reader.consume(count + usize::from(newline.is_some()));
|
||||
if newline.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same command syntax and execution path for --cmd and each command-file line.
|
||||
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
|
||||
let (id, params) = match command.split_once('=') {
|
||||
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (command, json!({})),
|
||||
};
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
|
||||
use std::io::BufReader;
|
||||
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut bytes = Vec::new();
|
||||
let mut line_number = 0usize;
|
||||
loop {
|
||||
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
|
||||
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
|
||||
break;
|
||||
}
|
||||
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
|
||||
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
|
||||
let command = line.trim();
|
||||
if command.is_empty() || command.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut sample = false;
|
||||
@@ -463,7 +549,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
|
||||
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
@@ -486,14 +573,10 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let mut s = Session::new();
|
||||
open(&mut s, input.as_deref(), sample)?;
|
||||
for c in cmds {
|
||||
let (id, p) = match c.split_once('=') {
|
||||
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (c.clone(), json!({})),
|
||||
};
|
||||
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
for source in cmds {
|
||||
match source {
|
||||
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
|
||||
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
|
||||
}
|
||||
}
|
||||
if let Some(p) = save {
|
||||
@@ -624,7 +707,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
let roots = AutomationRoots::new(&roots_args.read_roots, &roots_args.write_roots)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
@@ -641,8 +724,12 @@ so drop --connect (or use --connect without the roots)"
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
let show = |roots: Vec<String>| if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
|
||||
eprintln!(
|
||||
"deckcraft-cli: files confined: read roots {}, write roots {}",
|
||||
show(roots.read_roots().map(|r| r.display().to_string()).collect()),
|
||||
show(roots.write_roots().map(|r| r.display().to_string()).collect())
|
||||
);
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
@@ -663,6 +750,16 @@ mod tests {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_summary_keeps_median_and_max_from_one_population() {
|
||||
let slide_medians = [8.99, 16.565];
|
||||
let whole_deck = summarize_ms(vec![17.0, 18.0, 16.0]);
|
||||
assert_eq!(whole_deck, (17.0, 18.0));
|
||||
assert!(whole_deck.1 >= whole_deck.0);
|
||||
assert!(slide_medians.iter().sum::<f64>() > whole_deck.1, "fixture must expose summing independent slide medians");
|
||||
assert_eq!(summarize_ms(vec![]), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_prints_usage_and_writes_nothing() {
|
||||
assert!(render(&args(&["--help"])).is_ok());
|
||||
|
||||
@@ -20,6 +20,153 @@ fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn command_file_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn inspected_titles(out: &str) -> Vec<String> {
|
||||
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
|
||||
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
|
||||
let dir = command_file_dir("lines");
|
||||
let file = dir.join("commands with spaces.txt");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
|
||||
)
|
||||
.unwrap();
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
|
||||
let out = stdout(&o);
|
||||
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
|
||||
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 4);
|
||||
assert_eq!(titles[1], "Résumé = # 测试");
|
||||
assert_eq!(titles[3], "Last");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_files_and_inline_commands_keep_argument_order() {
|
||||
let dir = command_file_dir("mixed");
|
||||
let first = dir.join("first.txt");
|
||||
let second = dir.join("second.txt");
|
||||
let saved = dir.join("result.deckcraft");
|
||||
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
|
||||
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline first\"}",
|
||||
"--cmd-file",
|
||||
&first.to_string_lossy(),
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline middle\"}",
|
||||
"--cmd-file",
|
||||
&second.to_string_lossy(),
|
||||
"--cmd",
|
||||
"document.inspect",
|
||||
"--print",
|
||||
"--save",
|
||||
&saved.to_string_lossy(),
|
||||
]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
|
||||
assert_eq!(out.lines().count(), 5);
|
||||
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
|
||||
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
|
||||
let dir = command_file_dir("failure");
|
||||
let file = dir.join("commands.txt");
|
||||
let later = dir.join("later-in-file.deckcraft");
|
||||
let inline = dir.join("later-inline.deckcraft");
|
||||
let save = dir.join("saved.deckcraft");
|
||||
let export = dir.join("exported.png");
|
||||
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
|
||||
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
|
||||
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
|
||||
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
|
||||
std::fs::write(&save, b"keep saved file").unwrap();
|
||||
std::fs::write(&export, b"keep exported file").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--print",
|
||||
"--cmd-file",
|
||||
&file.to_string_lossy(),
|
||||
"--cmd",
|
||||
&inline_command,
|
||||
"--save",
|
||||
&save.to_string_lossy(),
|
||||
"--export",
|
||||
&export.to_string_lossy(),
|
||||
]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
|
||||
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
|
||||
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
|
||||
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_read_errors_name_the_path_and_utf8_line() {
|
||||
let dir = command_file_dir("read-errors");
|
||||
let missing = dir.join("missing.txt");
|
||||
let invalid = dir.join("invalid.txt");
|
||||
let save = dir.join("out.deckcraft");
|
||||
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
|
||||
for file in [&missing, &dir, &invalid] {
|
||||
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&file.display().to_string()), "{err}");
|
||||
if file == &invalid {
|
||||
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
|
||||
}
|
||||
assert!(!save.exists());
|
||||
}
|
||||
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_rejects_an_oversized_line_before_running_it() {
|
||||
let dir = command_file_dir("line-limit");
|
||||
let file = dir.join("commands.txt");
|
||||
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
|
||||
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
|
||||
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
|
||||
assert!(o.stdout.is_empty(), "the oversized command must not run");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_commands_keep_their_existing_syntax_and_errors() {
|
||||
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 3);
|
||||
assert_eq!(titles[2], "Inline = #");
|
||||
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
|
||||
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
@@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() {
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
|
||||
"commands execute in the order those options appear.",
|
||||
"If a command fails, run stops there and saves nothing.",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::fs::{self, File};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn closed_stdout() -> Stdio {
|
||||
let (writer, reader) = UnixStream::pair().unwrap();
|
||||
drop(reader);
|
||||
Stdio::from(OwnedFd::from(writer))
|
||||
}
|
||||
|
||||
fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_commands_or_save() {
|
||||
let dir = Scratch::new("save");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&[
|
||||
"run",
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"First"}"#,
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"Later"}"#,
|
||||
"--print",
|
||||
"--save",
|
||||
"out.deckcraft",
|
||||
],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let saved = dir.0.join("out.deckcraft");
|
||||
assert!(saved.is_file(), "requested save was skipped");
|
||||
let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap();
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
let slides = data["slides"].as_array().unwrap();
|
||||
assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_later_command_failure() {
|
||||
let dir = Scratch::new("command-failure");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(!out.status.success(), "a later command failure returned success");
|
||||
assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_save_failure() {
|
||||
let dir = Scratch::new("save-failure");
|
||||
fs::create_dir(dir.0.join("out.deckcraft")).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout());
|
||||
assert!(!out.status.success(), "a failed save returned success");
|
||||
assert!(!out.stderr.is_empty(), "missing save failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_rendered_slides() {
|
||||
let dir = Scratch::new("render-all");
|
||||
let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped());
|
||||
assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr));
|
||||
let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout());
|
||||
assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr));
|
||||
let names = |folder: &str| {
|
||||
let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let expected = names("normal");
|
||||
assert!(expected.len() > 1, "sample must have multiple slides");
|
||||
assert_eq!(names("closed"), expected, "rendering stopped after stdout closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_stdout_write_errors_fail() {
|
||||
let dir = Scratch::new("write-failure");
|
||||
let path = dir.0.join("readonly");
|
||||
fs::write(&path, b"").unwrap();
|
||||
let readonly = File::open(path).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly));
|
||||
assert!(!out.status.success(), "a non-broken stdout error returned success");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod cli;
|
||||
#[cfg(unix)]
|
||||
mod closed_stdout;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
@@ -87,6 +87,45 @@ fn mcp_automation_roots_confine_the_server() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_accepts_multiple_read_and_write_roots() {
|
||||
let base = tmpdir("multiple-roots");
|
||||
let read1 = base.join("read1");
|
||||
let read2 = base.join("read2");
|
||||
let write1 = base.join("write1");
|
||||
let write2 = base.join("write2");
|
||||
for dir in [&read1, &read2, &write1, &write2] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let (input1, input2) = (read1.join("one.txt"), read2.join("two.txt"));
|
||||
std::fs::write(&input1, "one").unwrap();
|
||||
std::fs::write(&input2, "two").unwrap();
|
||||
let (output1, output2) = (write1.join("one.deckcraft"), write2.join("two.deckcraft"));
|
||||
let args = [
|
||||
"--automation-read-root".to_owned(),
|
||||
read1.to_string_lossy().into_owned(),
|
||||
format!("--automation-read-root={}", read2.display()),
|
||||
"--automation-write-root".to_owned(),
|
||||
write1.to_string_lossy().into_owned(),
|
||||
format!("--automation-write-root={}", write2.display()),
|
||||
];
|
||||
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
("open_presentation", serde_json::json!({"path": input1})),
|
||||
("open_presentation", serde_json::json!({"path": input2})),
|
||||
("save_presentation", serde_json::json!({"path": output1})),
|
||||
("save_presentation", serde_json::json!({"path": output2})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(output1.exists());
|
||||
assert!(output2.exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
@@ -103,8 +142,12 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let mut full = vec!["mcp", flag, dir, flag];
|
||||
full.push(dir);
|
||||
let out = run(&full);
|
||||
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
|
||||
@@ -27,4 +27,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const PREFIX: &str = "#deckcraft-presenter=";
|
||||
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
fn origin() -> Result<String, String> {
|
||||
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||
}
|
||||
pub fn token() -> Option<String> {
|
||||
let hash = web_sys::window()?.location().hash().ok()?;
|
||||
let token = hash.strip_prefix(PREFIX)?;
|
||||
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||
}
|
||||
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||
}
|
||||
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err("Presenter message is too large".into());
|
||||
}
|
||||
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||
}
|
||||
struct State {
|
||||
popup: Option<web_sys::Window>,
|
||||
token: String,
|
||||
commands: VecDeque<Command>,
|
||||
ready: bool,
|
||||
refresh_images: bool,
|
||||
}
|
||||
pub struct BrowserHost {
|
||||
state: Rc<RefCell<State>>,
|
||||
origin: String,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||
images: DeltaCache,
|
||||
}
|
||||
impl BrowserHost {
|
||||
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let origin = origin()?;
|
||||
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||
let weak = Rc::downgrade(&state);
|
||||
let expected = origin.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||
let Some(popup) = state.popup.as_ref() else { return };
|
||||
if !same_source(&event, popup, &expected) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||
return;
|
||||
}
|
||||
match value.get("kind").and_then(|v| v.as_str()) {
|
||||
Some("ready") => {
|
||||
state.ready = true;
|
||||
state.refresh_images = true;
|
||||
}
|
||||
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
state.commands.push_back(command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||
}
|
||||
fn remove_button(&mut self) {
|
||||
if let Some((button, callback)) = self.button.take() {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
fn recovery_button(&mut self) {
|
||||
if self.button.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(body) = document.body() else { return };
|
||||
let Ok(element) = document.create_element("button") else { return };
|
||||
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||
button.set_text_content(Some("Open presenter window"));
|
||||
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||
let button_copy = button.clone();
|
||||
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||
if let Some(state) = weak.upgrade() {
|
||||
match open_popup(&state) {
|
||||
Ok(()) => {
|
||||
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||
}
|
||||
Err(_) => {
|
||||
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||
self.button = Some((button, callback));
|
||||
} else {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||
if let Some(popup) = &state.popup
|
||||
&& !popup.closed().unwrap_or(true)
|
||||
{
|
||||
let _ = popup.focus();
|
||||
return Ok(());
|
||||
}
|
||||
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||
url.set_hash(&format!("{PREFIX}{token}"));
|
||||
let popup = window
|
||||
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||
.map_err(error)?
|
||||
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||
if popup.closed().unwrap_or(true) {
|
||||
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||
}
|
||||
state.popup = Some(popup);
|
||||
state.token = token;
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
Ok(())
|
||||
}
|
||||
impl Host for BrowserHost {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.close();
|
||||
let result = open_popup(&self.state);
|
||||
if result.is_err() {
|
||||
self.recovery_button();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
self.state.borrow_mut().commands.drain(..).collect()
|
||||
}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
if !self.is_open() {
|
||||
self.recovery_button();
|
||||
return;
|
||||
}
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if !state.ready {
|
||||
return;
|
||||
}
|
||||
if let Some(popup) = state.popup.clone() {
|
||||
let token = state.token.clone();
|
||||
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||
send(
|
||||
&popup,
|
||||
&self.origin,
|
||||
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||
)
|
||||
});
|
||||
if sent.is_ok() {
|
||||
state.refresh_images = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(window) = state.popup.take() {
|
||||
let _ = window.close();
|
||||
}
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
state.token.clear();
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserHost {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup {
|
||||
token: String,
|
||||
origin: String,
|
||||
opener: web_sys::Window,
|
||||
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||
last_received: Rc<RefCell<f64>>,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
view: View,
|
||||
}
|
||||
impl Popup {
|
||||
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||
// exact source identity and origin before any data is accepted.
|
||||
let opener = window.opener().map_err(error)?;
|
||||
if opener.is_null() || opener.is_undefined() {
|
||||
return Err("The audience window is unavailable".into());
|
||||
}
|
||||
let opener: web_sys::Window = opener.unchecked_into();
|
||||
let origin = origin()?;
|
||||
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||
let received = last_received.clone();
|
||||
let target = snapshot.clone();
|
||||
let expected_origin = origin.clone();
|
||||
let expected_opener = opener.clone();
|
||||
let expected_token = token.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||
if let Some(previous) = target.borrow().as_ref() {
|
||||
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.current = previous.current.clone();
|
||||
}
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
if let Some(document) = window.document() {
|
||||
document.set_title("Presenter View — DeckCraft");
|
||||
}
|
||||
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||
Ok(popup)
|
||||
}
|
||||
}
|
||||
impl eframe::App for Popup {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.snapshot.borrow();
|
||||
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||
if !self.view.prepare(ui.ctx(), notes) {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if self.opener.closed().unwrap_or(true) {
|
||||
ui.heading("The audience window has closed.");
|
||||
return;
|
||||
}
|
||||
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||
ui.heading("The audience window is not responding.");
|
||||
ui.label("Return to the audience window to resume the connection.");
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
for command in self.view.ui(ui, snapshot) {
|
||||
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||
}
|
||||
} else {
|
||||
ui.label("Waiting for the audience window…");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ pub fn start() {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
if let Some(token) = crate::presenter::token() {
|
||||
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
host.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
|
||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||
Err(error) => {
|
||||
log::error!("Presenter window: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Services {
|
||||
presenter,
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -25,6 +25,7 @@ chrono = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -307,304 +307,4 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
fn small() -> Limits {
|
||||
Limits {
|
||||
max_clients: 2,
|
||||
max_line: 64,
|
||||
queue: 1,
|
||||
idle_timeout: Duration::from_millis(300),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
reply_timeout: Duration::from_millis(300),
|
||||
}
|
||||
}
|
||||
|
||||
fn server(limits: Limits) -> (SocketAddr, Receiver<ControlRequest>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
(addr, serve_listener(l, limits, egui::Context::default()))
|
||||
}
|
||||
|
||||
fn client(addr: SocketAddr) -> (BufReader<TcpStream>, TcpStream) {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(BufReader::new(s.try_clone().unwrap()), s)
|
||||
}
|
||||
|
||||
fn reply(r: &mut BufReader<TcpStream>) -> Option<Value> {
|
||||
let mut s = String::new();
|
||||
match r.read_line(&mut s) {
|
||||
Ok(0) | Err(_) => None,
|
||||
Ok(_) => serde_json::from_str(&s).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer every request with its method name until the server goes away.
|
||||
fn ui(rx: Receiver<ControlRequest>) {
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_round_trip() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert_eq!(v["id"], 7);
|
||||
assert_eq!(v["result"], "ui.inspect");
|
||||
}
|
||||
|
||||
/// Every reply line until the server closes the connection.
|
||||
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
|
||||
std::iter::from_fn(|| reply(r)).collect()
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(
|
||||
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(junk).unwrap();
|
||||
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
|
||||
/// line was rejected. Closing with its second line unread resets the connection and discards
|
||||
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
|
||||
/// depended on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
|
||||
// A slot comes back only when the thread serving a peer ends, which can be after the next
|
||||
// peer connects, so every peer has a slot of its own.
|
||||
const PEERS: usize = 12;
|
||||
let (addr, rx) = server(Limits { max_clients: PEERS, ..small() });
|
||||
for _ in 0..PEERS {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
// A second line, written after the first as a script writes it: it races the close.
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
|
||||
}
|
||||
// The line after the rejected one is never dispatched, pipelined or not.
|
||||
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
|
||||
}
|
||||
|
||||
/// A peer refused because every connection is taken still reads why when it had already
|
||||
/// written a line: closing with that line unread resets the connection and discards the
|
||||
/// reply. Repeated because it depends on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_refused_as_busy_still_reads_why() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
// Take the only slot, and make sure it is being served.
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
for _ in 0..50 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers refused as busy that stay connected without sending anything do not hold up the
|
||||
/// connections after them: each one's drain runs to its deadline on a thread of its own, and
|
||||
/// past `MAX_REFUSING` drains at once a refused connection is closed without one.
|
||||
#[test]
|
||||
fn silent_refused_peers_do_not_delay_the_next_refusal() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
// Twenty drains of 250 ms each, one after another, would take five seconds.
|
||||
let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect();
|
||||
let start = Instant::now();
|
||||
let (mut r, _w) = client(addr);
|
||||
let refused = reply(&mut r).unwrap();
|
||||
assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}");
|
||||
assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed());
|
||||
drop(silent);
|
||||
}
|
||||
|
||||
/// A peer that keeps its connection open after its line was rejected gives its slot back at
|
||||
/// the drain deadline (the write timeout), not at the idle timeout.
|
||||
#[test]
|
||||
fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
assert_eq!(reply(&mut r).unwrap()["ok"], false);
|
||||
// `w` stays open, sending nothing.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
drop(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(&[b'x'; 200]).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("longer than 64"));
|
||||
assert!(reply(&mut r).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_connection_is_closed() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence
|
||||
let t = Instant::now();
|
||||
assert!(reply(&mut r).is_none());
|
||||
assert!(t.elapsed() < Duration::from_secs(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_refuses_then_frees_slot() {
|
||||
// `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the
|
||||
// third peer connected, and that peer then took its slot instead of reading `busy`.
|
||||
let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let a = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
// Make sure both are being served before the third arrives.
|
||||
writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut br).is_some());
|
||||
let (mut cr, _cw) = client(addr);
|
||||
let v = reply(&mut cr).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().starts_with("busy"));
|
||||
drop(a);
|
||||
// The freed slot is reusable once the server notices the disconnect.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_ui_times_out_and_full_queue_is_busy() {
|
||||
let (addr, rx) = server(small());
|
||||
// No UI: the request that reaches the one-slot queue first fills it and times out; the
|
||||
// other finds it full. Each connection has its own thread, so either request can be first.
|
||||
let (mut ar, mut aw) = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
let a = reply(&mut ar).unwrap();
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2)));
|
||||
let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()];
|
||||
errors.sort_unstable();
|
||||
assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request that timed out while it was still queued must not run when the window resumes (#719).
|
||||
#[test]
|
||||
fn timed_out_request_does_not_run_when_the_window_resumes() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
|
||||
let t = reply(&mut r).unwrap();
|
||||
assert_eq!(t["error"], "timeout");
|
||||
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
|
||||
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().doc.slides.len();
|
||||
let req = rx.try_recv().expect("the timed-out request is still queued");
|
||||
if req.take() {
|
||||
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request the app had already started when the reply timed out reports an unknown outcome,
|
||||
/// not a definite failure (#719).
|
||||
#[test]
|
||||
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
|
||||
let (addr, rx) = server(small());
|
||||
let ui = std::thread::spawn(move || {
|
||||
let req = rx.recv().expect("the request is queued");
|
||||
assert!(req.take(), "the app claims the request");
|
||||
// Still running it when the sender's wait expires.
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
|
||||
});
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
|
||||
ui.join().unwrap();
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
fn small() -> Limits {
|
||||
Limits {
|
||||
max_clients: 2,
|
||||
max_line: 64,
|
||||
queue: 1,
|
||||
idle_timeout: Duration::from_millis(300),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
reply_timeout: Duration::from_millis(300),
|
||||
}
|
||||
}
|
||||
|
||||
fn server(limits: Limits) -> (SocketAddr, Receiver<ControlRequest>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
(addr, serve_listener(l, limits, egui::Context::default()))
|
||||
}
|
||||
|
||||
fn client(addr: SocketAddr) -> (BufReader<TcpStream>, TcpStream) {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(BufReader::new(s.try_clone().unwrap()), s)
|
||||
}
|
||||
|
||||
fn reply(r: &mut BufReader<TcpStream>) -> Option<Value> {
|
||||
let mut s = String::new();
|
||||
match r.read_line(&mut s) {
|
||||
Ok(0) | Err(_) => None,
|
||||
Ok(_) => serde_json::from_str(&s).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer every request with its method name until the server goes away.
|
||||
fn ui(rx: Receiver<ControlRequest>) {
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_round_trip() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert_eq!(v["id"], 7);
|
||||
assert_eq!(v["result"], "ui.inspect");
|
||||
}
|
||||
|
||||
/// Every reply line until the server closes the connection.
|
||||
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
|
||||
std::iter::from_fn(|| reply(r)).collect()
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(
|
||||
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(junk).unwrap();
|
||||
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
|
||||
/// line was rejected. Closing with its second line unread resets the connection and discards
|
||||
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
|
||||
/// depended on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
|
||||
// A slot comes back only when the thread serving a peer ends, which can be after the next
|
||||
// peer connects, so every peer has a slot of its own.
|
||||
const PEERS: usize = 12;
|
||||
let (addr, rx) = server(Limits { max_clients: PEERS, ..small() });
|
||||
for _ in 0..PEERS {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
// A second line, written after the first as a script writes it: it races the close.
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
|
||||
}
|
||||
// The line after the rejected one is never dispatched, pipelined or not.
|
||||
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
|
||||
}
|
||||
|
||||
/// A peer refused because every connection is taken still reads why when it had already
|
||||
/// written a line: closing with that line unread resets the connection and discards the
|
||||
/// reply. Repeated because it depends on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_refused_as_busy_still_reads_why() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
// Take the only slot, and make sure it is being served.
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
for _ in 0..50 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers refused as busy that stay connected without sending anything do not hold up the
|
||||
/// connections after them: each one's drain runs to its deadline on a thread of its own, and
|
||||
/// past `MAX_REFUSING` drains at once a refused connection is closed without one.
|
||||
#[test]
|
||||
fn silent_refused_peers_do_not_delay_the_next_refusal() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
// Twenty drains of 250 ms each, one after another, would take five seconds.
|
||||
let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect();
|
||||
let start = Instant::now();
|
||||
let (mut r, _w) = client(addr);
|
||||
let refused = reply(&mut r).unwrap();
|
||||
assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}");
|
||||
assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed());
|
||||
drop(silent);
|
||||
}
|
||||
|
||||
/// A peer that keeps its connection open after its line was rejected gives its slot back at
|
||||
/// the drain deadline (the write timeout), not at the idle timeout.
|
||||
#[test]
|
||||
fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
assert_eq!(reply(&mut r).unwrap()["ok"], false);
|
||||
// `w` stays open, sending nothing.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
drop(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(&[b'x'; 200]).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("longer than 64"));
|
||||
assert!(reply(&mut r).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_connection_is_closed() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence
|
||||
let t = Instant::now();
|
||||
assert!(reply(&mut r).is_none());
|
||||
assert!(t.elapsed() < Duration::from_secs(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_refuses_then_frees_slot() {
|
||||
// `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the
|
||||
// third peer connected, and that peer then took its slot instead of reading `busy`.
|
||||
let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let a = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
// Make sure both are being served before the third arrives.
|
||||
writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut br).is_some());
|
||||
let (mut cr, _cw) = client(addr);
|
||||
let v = reply(&mut cr).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().starts_with("busy"));
|
||||
drop(a);
|
||||
// The freed slot is reusable once the server notices the disconnect.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_ui_times_out_and_full_queue_is_busy() {
|
||||
let (addr, rx) = server(small());
|
||||
// No UI: the request that reaches the one-slot queue first fills it and times out; the
|
||||
// other finds it full. Each connection has its own thread, so either request can be first.
|
||||
let (mut ar, mut aw) = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
let a = reply(&mut ar).unwrap();
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2)));
|
||||
let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()];
|
||||
errors.sort_unstable();
|
||||
assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request that timed out while it was still queued must not run when the window resumes (#719).
|
||||
#[test]
|
||||
fn timed_out_request_does_not_run_when_the_window_resumes() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
|
||||
let t = reply(&mut r).unwrap();
|
||||
assert_eq!(t["error"], "timeout");
|
||||
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
|
||||
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().doc.slides.len();
|
||||
let req = rx.try_recv().expect("the timed-out request is still queued");
|
||||
if req.take() {
|
||||
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request the app had already started when the reply timed out reports an unknown outcome,
|
||||
/// not a definite failure (#719).
|
||||
#[test]
|
||||
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
|
||||
let (addr, rx) = server(small());
|
||||
let ui = std::thread::spawn(move || {
|
||||
let req = rx.recv().expect("the request is queued");
|
||||
assert!(req.take(), "the app claims the request");
|
||||
// Still running it when the sender's wait expires.
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
|
||||
});
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
|
||||
ui.join().unwrap();
|
||||
}
|
||||
@@ -60,6 +60,8 @@ impl eframe::App for App {
|
||||
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
|
||||
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.0.cleanup_print_previews();
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
@@ -187,6 +189,13 @@ fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
if let Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("DeckCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (control_port, files, sample, show) =
|
||||
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
|
||||
Startup::Help => {
|
||||
@@ -261,6 +270,7 @@ fn main() -> eframe::Result {
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let mut app = SlideApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
app.enable_update_checks();
|
||||
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
|
||||
if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {
|
||||
app.session.recovery_dir = deckcraft_engine::recovery::default_dir();
|
||||
@@ -422,4 +432,27 @@ mod tests {
|
||||
assert_eq!(mac, win, "macOS and Windows offer different file types");
|
||||
assert_eq!(mac, linux, "macOS and Linux offer different file types");
|
||||
}
|
||||
|
||||
/// #1114: a shortcut that names an MSI `<Icon>` shows a blank icon once an update deletes the
|
||||
/// cached copy of it, and a taskbar pin (a copy of the shortcut) is never repaired. Shortcuts
|
||||
/// take the icon embedded in `deckcraft.exe`; only `ARPPRODUCTICON` uses the MSI `<Icon>`.
|
||||
#[test]
|
||||
fn windows_shortcuts_use_the_icon_embedded_in_the_exe() {
|
||||
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
|
||||
let shortcuts: Vec<&str> = wxs.split("<Shortcut").skip(1).filter_map(|s| s.split("/>").next()).collect();
|
||||
assert!(!shortcuts.is_empty(), "deckcraft.wxs has no shortcut");
|
||||
for shortcut in shortcuts {
|
||||
assert!(!shortcut.contains("Icon="), "a shortcut names an MSI <Icon>:{shortcut}");
|
||||
}
|
||||
assert!(wxs.contains("<Property Id=\"ARPPRODUCTICON\""), "Apps and features keeps its icon");
|
||||
}
|
||||
|
||||
/// The shortcuts take the exe's own icon, and `package.ps1 -SkipBuild` packages an existing
|
||||
/// exe without `DECKCRAFT_REQUIRE_WINRES`, so the script has to refuse one with no icon.
|
||||
#[test]
|
||||
fn the_windows_package_script_rejects_an_exe_without_an_icon() {
|
||||
let script = include_str!("../../../packaging/windows/package.ps1");
|
||||
assert!(script.contains("ExtractIconEx"), "package.ps1 does not count the exe's icons");
|
||||
assert!(script.contains("has no embedded icon"), "package.ps1 does not reject an iconless exe");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -62,3 +59,17 @@ sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Katacc]
|
||||
sync_github_name = true
|
||||
|
||||
[people.SofiaAFigueredo]
|
||||
real_name = "Sofia Alves Figueredo"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
+59
-29
@@ -1,5 +1,7 @@
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
|
||||
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
||||
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
||||
/// once; top-level shapes only (groups move whole).
|
||||
///
|
||||
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
|
||||
/// pass: matching costs the sum of the two shape lists, not their product.
|
||||
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||
let mut used_a = vec![false; a.shapes.len()];
|
||||
let mut used_b = vec![false; b.shapes.len()];
|
||||
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
|
||||
&|x, y| x.name.starts_with("!!") && x.name == y.name,
|
||||
&|x, y| !x.name.is_empty() && x.name == y.name,
|
||||
&|x, y| {
|
||||
if x.kind_name() != y.kind_name() {
|
||||
return false;
|
||||
// Each shape's text, worked out once rather than inside every comparison.
|
||||
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
|
||||
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
|
||||
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
|
||||
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
|
||||
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
|
||||
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
|
||||
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
|
||||
_ => None,
|
||||
};
|
||||
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
|
||||
for key in passes {
|
||||
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
|
||||
// match, which is the one a search through `b` would find.
|
||||
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
|
||||
for (j, sb) in b.shapes.iter().enumerate() {
|
||||
if used_b.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let tx = text_of(x);
|
||||
!tx.trim().is_empty() && tx == text_of(y)
|
||||
},
|
||||
&|x, y| {
|
||||
x.kind_name() == y.kind_name()
|
||||
&& x.geom.preset_name().is_some()
|
||||
&& x.geom.preset_name() == y.geom.preset_name()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
|
||||
index.entry(k).or_default().push_back(j);
|
||||
}
|
||||
}
|
||||
if index.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, sa) in a.shapes.iter().enumerate() {
|
||||
if used_a.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
|
||||
if let Some((j, sb)) = found {
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
|
||||
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
|
||||
let Some(sb) = b.shapes.get(j) else { continue };
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// What one [`morph_pairs`] pass matches shapes on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum MorphKey {
|
||||
Name(String),
|
||||
Text(&'static str, String),
|
||||
Geom(&'static str, String),
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
let v = a + (b - a) * t;
|
||||
if v.is_finite() {
|
||||
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
f.shapes.push((sh, old));
|
||||
id
|
||||
};
|
||||
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
|
||||
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
|
||||
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
|
||||
for (ia, ib) in &pairs {
|
||||
partner.entry(*ib).or_insert(*ia);
|
||||
}
|
||||
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
|
||||
for x in &a.shapes {
|
||||
a_by_id.entry(x.id).or_insert(x);
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
if !paired_a.contains(&sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
for sb in &b.shapes {
|
||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
||||
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
|
||||
let Some(sa) = pair else {
|
||||
if !hidden_b(sb.id) {
|
||||
push(sb.clone(), false, s);
|
||||
|
||||
+8
-12
@@ -29,18 +29,8 @@ pub enum ShowAction {
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
|
||||
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
|
||||
/// opened on a click.
|
||||
pub fn safe_url(url: &str) -> bool {
|
||||
let u = url.trim();
|
||||
let Some((scheme, rest)) = u.split_once(':') else { return false };
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
|
||||
"mailto" => !rest.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// Which links the show opens on a click; PDF export writes the same ones.
|
||||
pub use deckcraft_model::text::safe_url;
|
||||
|
||||
/// What a clicked link does in the show (`ShowState::follow`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -68,6 +58,9 @@ pub struct ShowState {
|
||||
pub playing: bool,
|
||||
/// On the end screen.
|
||||
pub ended: bool,
|
||||
/// The current slide was reached by going back (← / Backspace), so the show plays the
|
||||
/// transition of the slide it left, in reverse, as PowerPoint does (#1531).
|
||||
pub backward: bool,
|
||||
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
|
||||
active: bool,
|
||||
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
|
||||
@@ -218,6 +211,7 @@ impl ShowState {
|
||||
self.active = true;
|
||||
self.slide = i;
|
||||
self.ended = false;
|
||||
self.backward = false;
|
||||
// Time spent elsewhere never counts for this slide's triggered animations.
|
||||
self.last_now = None;
|
||||
let steps = ShowState::steps(pres, i);
|
||||
@@ -319,6 +313,7 @@ impl ShowState {
|
||||
}
|
||||
self.step = ShowState::steps(pres, self.slide);
|
||||
self.playing = false;
|
||||
self.backward = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
@@ -347,6 +342,7 @@ impl ShowState {
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
}
|
||||
self.backward = true;
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None => ShowAction::None,
|
||||
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::{
|
||||
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
|
||||
};
|
||||
|
||||
use crate::*;
|
||||
use crate::{
|
||||
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
|
||||
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
|
||||
};
|
||||
|
||||
const W: f64 = 960.0;
|
||||
const H: f64 = 540.0;
|
||||
@@ -760,6 +763,26 @@ fn show_skips_hidden_slides() {
|
||||
assert_eq!(s.goto(&p, 99), ShowAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_remembers_a_slide_was_reached_by_going_back() {
|
||||
// #1531: the UI plays the left slide's transition reversed only for Back.
|
||||
let p = pres(3);
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0));
|
||||
assert!(s.backward);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.goto(&p, 0), ShowAction::Slide(0));
|
||||
assert!(!s.backward, "a jump is not Back");
|
||||
// Back from the end screen shows the last slide again, without a transition to reverse.
|
||||
assert_eq!(s.goto(&p, 2), ShowAction::Slide(2));
|
||||
assert_eq!(s.next(&p), ShowAction::End);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(2));
|
||||
assert!(!s.backward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_loops() {
|
||||
let mut p = pres(3);
|
||||
@@ -1132,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
|
||||
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
|
||||
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
|
||||
// the shape of `b` left over.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_of_large_slides_stays_linear() {
|
||||
let n = 20_000u32;
|
||||
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
|
||||
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
|
||||
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
|
||||
// One named pair at opposite ends.
|
||||
a.shapes[0].name = "Logo".into();
|
||||
b.shapes[n as usize - 1].name = "Logo".into();
|
||||
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
|
||||
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
|
||||
assert_eq!(f.shapes.len(), 2 * n as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_states_of_a_large_build_stay_linear() {
|
||||
let n = 20_000usize;
|
||||
let mut s = slide_with(vec![]);
|
||||
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
|
||||
// One first-level paragraph owning every other paragraph.
|
||||
for p in body.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
}
|
||||
s.shapes[0].text = Some(body);
|
||||
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
s.animations = vec![a];
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
|
||||
for p in 0..n {
|
||||
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
|
||||
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
|
||||
}
|
||||
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
|
||||
|
||||
use crate::*;
|
||||
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
|
||||
|
||||
fn shape(id: u32) -> Shape {
|
||||
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
|
||||
|
||||
+47
-12
@@ -103,6 +103,41 @@ pub struct Timeline {
|
||||
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
|
||||
/// (shape, member paragraph, owner paragraph).
|
||||
groups: Vec<(ShapeId, usize, usize)>,
|
||||
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
|
||||
/// rather than a scan of every effect, group and colour on the slide.
|
||||
index: TimelineIndex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TimelineIndex {
|
||||
/// Effects (indices into `effects`, in order) by target shape and paragraph.
|
||||
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
|
||||
/// Effects (indices into `effects`, in order) by target shape.
|
||||
by_shape: HashMap<ShapeId, Vec<usize>>,
|
||||
/// Owner paragraph of each member paragraph (the first group recorded wins).
|
||||
owner: HashMap<(ShapeId, usize), usize>,
|
||||
/// Groups (member, owner) by shape, in order.
|
||||
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
|
||||
/// Base colour by shape (the first recorded wins).
|
||||
color: HashMap<ShapeId, Rgba>,
|
||||
}
|
||||
|
||||
impl TimelineIndex {
|
||||
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
|
||||
let mut ix = TimelineIndex::default();
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
|
||||
ix.by_shape.entry(e.timing.shape).or_default().push(i);
|
||||
}
|
||||
for &(shape, member, owner) in groups {
|
||||
ix.owner.entry((shape, member)).or_insert(owner);
|
||||
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
|
||||
}
|
||||
for &(shape, c) in colors {
|
||||
ix.color.entry(shape).or_insert(c);
|
||||
}
|
||||
ix
|
||||
}
|
||||
}
|
||||
|
||||
enum Status {
|
||||
@@ -324,6 +359,7 @@ impl Timeline {
|
||||
};
|
||||
tl.colors.push((id, c));
|
||||
}
|
||||
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
|
||||
tl
|
||||
}
|
||||
|
||||
@@ -413,8 +449,12 @@ impl Timeline {
|
||||
|
||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
|
||||
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
|
||||
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
|
||||
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
|
||||
let owners: HashSet<usize> = v.iter().copied().collect();
|
||||
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
|
||||
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
|
||||
}
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
@@ -422,13 +462,8 @@ impl Timeline {
|
||||
|
||||
/// Shapes with any effect.
|
||||
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
||||
let mut v: Vec<ShapeId> = Vec::new();
|
||||
for e in &self.effects {
|
||||
if !v.contains(&e.timing.shape) {
|
||||
v.push(e.timing.shape);
|
||||
}
|
||||
}
|
||||
v
|
||||
let mut seen: HashSet<ShapeId> = HashSet::new();
|
||||
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
|
||||
}
|
||||
|
||||
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
||||
@@ -456,9 +491,9 @@ impl Timeline {
|
||||
triggers: &[TriggerPlay],
|
||||
stamps: &[StepStamp],
|
||||
) -> AnimState {
|
||||
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
|
||||
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
|
||||
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
|
||||
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let mut st = AnimState::default();
|
||||
// Initial visibility comes from the first effect in the Animation Pane order.
|
||||
let mut ordered: Vec<&Effect> = mine.collect();
|
||||
|
||||
+115
-74
@@ -45,7 +45,7 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
|
||||
let var_str = if (var >> 6) != 0 { (31 - ((var >> 6) as u32).leading_zeros()).min(12) as i32 } else { 0 };
|
||||
pri = if var != 0 { (pri * (4 + var_str) + 8) >> 4 } else { 0 };
|
||||
let damping = cp.damping as i32 + coeff_shift as i32;
|
||||
filter(fs, out, 0, r, c, pri, sec, damping, dir);
|
||||
filter(fs, out, 0, (r, c), (pri, sec), damping, dir);
|
||||
if fs.num_planes == 1 {
|
||||
return true;
|
||||
}
|
||||
@@ -53,8 +53,8 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
|
||||
let sec = (cp.uv_sec[idx] << coeff_shift) as i32;
|
||||
let dir = if pri == 0 { 0 } else { CDEF_UV_DIR[fs.ssx][fs.ssy][y_dir] as usize };
|
||||
let damping = cp.damping as i32 + coeff_shift as i32 - 1;
|
||||
filter(fs, out, 1, r, c, pri, sec, damping, dir);
|
||||
filter(fs, out, 2, r, c, pri, sec, damping, dir);
|
||||
filter(fs, out, 1, (r, c), (pri, sec), damping, dir);
|
||||
filter(fs, out, 2, (r, c), (pri, sec), damping, dir);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -124,33 +124,74 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
|
||||
if diff < 0 { -mag } else { mag }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
|
||||
if pri == 0 && sec == 0 {
|
||||
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
|
||||
return;
|
||||
/// Tap offsets, weights and strengths of one block.
|
||||
struct Taps {
|
||||
pri: i32,
|
||||
sec: i32,
|
||||
pri_adj: u32,
|
||||
sec_adj: u32,
|
||||
pri_off: [isize; 2],
|
||||
sec_off: [[isize; 2]; 2],
|
||||
pri_taps: [i32; 2],
|
||||
sec_taps: [i32; 2],
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
fn new(coeff_shift: u32, (pri, sec): (i32, i32), damping: i32, dir: usize) -> Taps {
|
||||
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
|
||||
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
|
||||
let pri_off = [off(dir, 0), off(dir, 1)];
|
||||
let d_lo = (dir + 6) & 7;
|
||||
let d_hi = (dir + 2) & 7;
|
||||
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
|
||||
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
|
||||
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
|
||||
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
|
||||
Taps { pri, sec, pri_adj: adj(pri), sec_adj: adj(sec), pri_off, sec_off, pri_taps, sec_taps }
|
||||
}
|
||||
let coeff_shift = fs.bit_depth - 8;
|
||||
|
||||
/// The 12 (offset, weight, strength, adjustment) taps in filtering order.
|
||||
fn tap_list(&self) -> [(isize, i32, i32, u32); 12] {
|
||||
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
|
||||
let mut n = 0;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
taps[n] = (sign * self.pri_off[k], self.pri_taps[k], self.pri, self.pri_adj);
|
||||
taps[n + 1] = (sign * self.sec_off[k][0], self.sec_taps[k], self.sec, self.sec_adj);
|
||||
taps[n + 2] = (sign * self.sec_off[k][1], self.sec_taps[k], self.sec, self.sec_adj);
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
taps
|
||||
}
|
||||
}
|
||||
|
||||
/// Position and size of a block within its plane.
|
||||
#[derive(Clone, Copy)]
|
||||
struct BlockRect {
|
||||
x0: usize,
|
||||
y0: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
/// The block with two samples of padding on every side, plus whether every sample is available
|
||||
/// (CdefAvailable: its 4x4 block is inside the frame).
|
||||
fn padded_block(fs: &FrameState, plane: usize, src: &Plane, rect: BlockRect) -> ([i32; PAD_STRIDE * PAD_STRIDE], bool) {
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let x0 = (c * 4) >> sub_x;
|
||||
let y0 = (r * 4) >> sub_y;
|
||||
let w = 8 >> sub_x;
|
||||
let h = 8 >> sub_y;
|
||||
let src = &fs.cur.planes[plane];
|
||||
let (mi_rows, mi_cols) = (fs.fh.mi_rows as isize, fs.fh.mi_cols as isize);
|
||||
// CdefAvailable per sample: its 4x4 block is inside the frame.
|
||||
let mut buf = [CDEF_NA; PAD_STRIDE * PAD_STRIDE];
|
||||
let mut all_avail = true;
|
||||
for i in 0..h + 4 {
|
||||
let y = (y0 + i) as isize - 2;
|
||||
for i in 0..rect.h + 4 {
|
||||
let y = (rect.y0 + i) as isize - 2;
|
||||
let cand_r = (y << sub_y) >> 2;
|
||||
if y < 0 || cand_r >= mi_rows {
|
||||
all_avail = false;
|
||||
continue;
|
||||
}
|
||||
let row = src.row(y as usize);
|
||||
for j in 0..w + 4 {
|
||||
let x = (x0 + j) as isize - 2;
|
||||
for j in 0..rect.w + 4 {
|
||||
let x = (rect.x0 + j) as isize - 2;
|
||||
let cand_c = (x << sub_x) >> 2;
|
||||
if x >= 0 && cand_c < mi_cols {
|
||||
buf[i * PAD_STRIDE + j] = row[x as usize] as i32;
|
||||
@@ -159,70 +200,70 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
|
||||
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
|
||||
let pri_off = [off(dir, 0), off(dir, 1)];
|
||||
let d_lo = (dir + 6) & 7;
|
||||
let d_hi = (dir + 2) & 7;
|
||||
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
|
||||
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
|
||||
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
|
||||
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
|
||||
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
|
||||
(buf, all_avail)
|
||||
}
|
||||
|
||||
/// Filters one sample whose taps may be unavailable (CDEF_NA).
|
||||
fn filter_sample(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &Taps, ci: isize, x: i32) -> u16 {
|
||||
let mut sum = 0i32;
|
||||
let mut max = x;
|
||||
let mut min = x;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
let p = buf[(ci + sign * taps.pri_off[k]) as usize];
|
||||
if p != CDEF_NA {
|
||||
if taps.pri != 0 {
|
||||
sum += taps.pri_taps[k] * constrain(p - x, taps.pri, taps.pri_adj);
|
||||
}
|
||||
max = max.max(p);
|
||||
min = min.min(p);
|
||||
}
|
||||
for s in 0..2 {
|
||||
let q = buf[(ci + sign * taps.sec_off[k][s]) as usize];
|
||||
if q != CDEF_NA {
|
||||
if taps.sec != 0 {
|
||||
sum += taps.sec_taps[k] * constrain(q - x, taps.sec, taps.sec_adj);
|
||||
}
|
||||
max = max.max(q);
|
||||
min = min.min(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16
|
||||
}
|
||||
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, (r, c): (usize, usize), strengths: (i32, i32), damping: i32, dir: usize) {
|
||||
let (pri, sec) = strengths;
|
||||
if pri == 0 && sec == 0 {
|
||||
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
|
||||
return;
|
||||
}
|
||||
let coeff_shift = fs.bit_depth - 8;
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let rect = BlockRect { x0: (c * 4) >> sub_x, y0: (r * 4) >> sub_y, w: 8 >> sub_x, h: 8 >> sub_y };
|
||||
let src = &fs.cur.planes[plane];
|
||||
let (buf, all_avail) = padded_block(fs, plane, src, rect);
|
||||
let taps = Taps::new(coeff_shift, strengths, damping, dir);
|
||||
let dst = &mut out.planes[plane];
|
||||
if all_avail {
|
||||
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
|
||||
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
|
||||
let mut n = 0;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
taps[n] = (sign * pri_off[k], pri_taps[k], pri, pri_adj);
|
||||
taps[n + 1] = (sign * sec_off[k][0], sec_taps[k], sec, sec_adj);
|
||||
taps[n + 2] = (sign * sec_off[k][1], sec_taps[k], sec, sec_adj);
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
let rows = (y0, x0, h);
|
||||
match w {
|
||||
8 => filter_rows::<8>(&buf, &taps, dst, rows),
|
||||
_ => filter_rows::<4>(&buf, &taps, dst, rows),
|
||||
let list = taps.tap_list();
|
||||
let rows = (rect.y0, rect.x0, rect.h);
|
||||
match rect.w {
|
||||
8 => filter_rows::<8>(&buf, &list, dst, rows),
|
||||
_ => filter_rows::<4>(&buf, &list, dst, rows),
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..h {
|
||||
for i in 0..rect.h {
|
||||
// The centre sample is read even where CdefAvailable is 0 (8x8 blocks straddling an odd
|
||||
// MiRows / MiCols edge).
|
||||
let srow = &src.row(y0 + i)[x0..x0 + w];
|
||||
let drow = &mut dst.row_mut(y0 + i)[x0..x0 + w];
|
||||
for j in 0..w {
|
||||
let srow = &src.row(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
|
||||
let drow = &mut dst.row_mut(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
|
||||
for j in 0..rect.w {
|
||||
let ci = ((i + 2) * PAD_STRIDE + j + 2) as isize;
|
||||
let x = srow[j] as i32;
|
||||
let mut sum = 0i32;
|
||||
let mut max = x;
|
||||
let mut min = x;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
let p = buf[(ci + sign * pri_off[k]) as usize];
|
||||
if p != CDEF_NA {
|
||||
if pri != 0 {
|
||||
sum += pri_taps[k] * constrain(p - x, pri, pri_adj);
|
||||
}
|
||||
max = max.max(p);
|
||||
min = min.min(p);
|
||||
}
|
||||
for s in 0..2 {
|
||||
let q = buf[(ci + sign * sec_off[k][s]) as usize];
|
||||
if q != CDEF_NA {
|
||||
if sec != 0 {
|
||||
sum += sec_taps[k] * constrain(q - x, sec, sec_adj);
|
||||
}
|
||||
max = max.max(q);
|
||||
min = min.min(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drow[j] = (x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16;
|
||||
drow[j] = filter_sample(&buf, &taps, ci, srow[j] as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
-54
@@ -405,49 +405,35 @@ impl Decoder {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether an OBU belongs to the selected operating point (spec 7.5: OBUs of other layers are dropped).
|
||||
fn in_operating_point(&self, obu: &ObuUnit) -> bool {
|
||||
if obu.obu_type != OBU_SEQUENCE_HEADER_T
|
||||
&& obu.obu_type != OBU_TEMPORAL_DELIMITER_T
|
||||
&& obu.ext
|
||||
&& let Some(seq) = &self.seq
|
||||
&& seq.op_idc != 0
|
||||
{
|
||||
let idc = seq.op_idc;
|
||||
let in_t = (idc >> obu.temporal_id) & 1;
|
||||
let in_s = (idc >> (obu.spatial_id + 8)) & 1;
|
||||
if in_t == 0 || in_s == 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
|
||||
let mut tu = 0usize;
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let h = data[pos];
|
||||
if h & 0x80 != 0 {
|
||||
return Err(Error::Invalid("obu_forbidden_bit"));
|
||||
let obu = parse_obu_unit(data, pos)?;
|
||||
pos = obu.end;
|
||||
if !self.in_operating_point(&obu) {
|
||||
continue;
|
||||
}
|
||||
let obu_type = ((h >> 3) & 0xf) as u32;
|
||||
let ext = h & 4 != 0;
|
||||
let has_size = h & 2 != 0;
|
||||
let mut p = pos + 1;
|
||||
let (mut temporal_id, mut spatial_id) = (0, 0);
|
||||
if ext {
|
||||
let e = *data.get(p).ok_or(Error::Truncated)?;
|
||||
temporal_id = (e >> 5) as u32;
|
||||
spatial_id = ((e >> 3) & 3) as u32;
|
||||
p += 1;
|
||||
}
|
||||
let size = if has_size {
|
||||
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
|
||||
p += n;
|
||||
v as usize
|
||||
} else {
|
||||
data.len().checked_sub(p).ok_or(Error::Truncated)?
|
||||
};
|
||||
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
|
||||
let payload = &data[p..end];
|
||||
pos = end;
|
||||
if obu_type != OBU_SEQUENCE_HEADER_T
|
||||
&& obu_type != OBU_TEMPORAL_DELIMITER_T
|
||||
&& ext
|
||||
&& let Some(seq) = &self.seq
|
||||
&& seq.op_idc != 0
|
||||
{
|
||||
let idc = seq.op_idc;
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if in_t == 0 || in_s == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match obu_type {
|
||||
let (payload, spatial_id) = (obu.payload, obu.spatial_id);
|
||||
match obu.obu_type {
|
||||
OBU_SEQUENCE_HEADER_T => {
|
||||
let s = SequenceHeader::parse(payload)?;
|
||||
self.seq = Some(s);
|
||||
@@ -457,24 +443,11 @@ impl Decoder {
|
||||
tu += 1;
|
||||
}
|
||||
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
|
||||
if self.seen_frame_header {
|
||||
// frame_header_copy(): identical to the active header
|
||||
if obu_type != OBU_FRAME_T {
|
||||
continue;
|
||||
if let Some(o) = self.frame_obu(&obu)? {
|
||||
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
|
||||
return Err(Error::Invalid("too many frame outputs in chunk"));
|
||||
}
|
||||
}
|
||||
let mut r = BitReader::new(payload);
|
||||
let shown = self.frame_header(&mut r, temporal_id, spatial_id)?;
|
||||
if let Some(o) = shown {
|
||||
out.push((o, tu, spatial_id));
|
||||
continue;
|
||||
}
|
||||
if obu_type == OBU_FRAME_T {
|
||||
r.byte_align();
|
||||
let rest = payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
|
||||
if let Some(o) = self.tile_group(rest)? {
|
||||
out.push((o, tu, spatial_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
OBU_TILE_GROUP_T => {
|
||||
@@ -489,6 +462,22 @@ impl Decoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A frame header or frame OBU: returns the picture it completes, if any.
|
||||
fn frame_obu(&mut self, obu: &ObuUnit) -> Result<Option<Output>> {
|
||||
// frame_header_copy(): identical to the active header
|
||||
if self.seen_frame_header && obu.obu_type != OBU_FRAME_T {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut r = BitReader::new(obu.payload);
|
||||
let shown = self.frame_header(&mut r, obu.temporal_id, obu.spatial_id)?;
|
||||
if shown.is_some() || obu.obu_type != OBU_FRAME_T {
|
||||
return Ok(shown);
|
||||
}
|
||||
r.byte_align();
|
||||
let rest = obu.payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
|
||||
self.tile_group(rest)
|
||||
}
|
||||
|
||||
/// frame_header_obu( ): returns the output for show_existing_frame.
|
||||
fn frame_header(&mut self, r: &mut BitReader, temporal_id: u32, spatial_id: u32) -> Result<Option<Output>> {
|
||||
let seq = self.seq.clone().ok_or(Error::Invalid("frame before sequence header"))?;
|
||||
@@ -1014,6 +1003,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One OBU: its header fields, payload and the position just past it.
|
||||
struct ObuUnit<'a> {
|
||||
obu_type: u32,
|
||||
ext: bool,
|
||||
temporal_id: u32,
|
||||
spatial_id: u32,
|
||||
payload: &'a [u8],
|
||||
end: usize,
|
||||
}
|
||||
|
||||
fn parse_obu_unit(data: &[u8], pos: usize) -> Result<ObuUnit<'_>> {
|
||||
let h = data[pos];
|
||||
if h & 0x80 != 0 {
|
||||
return Err(Error::Invalid("obu_forbidden_bit"));
|
||||
}
|
||||
let obu_type = ((h >> 3) & 0xf) as u32;
|
||||
let ext = h & 4 != 0;
|
||||
let has_size = h & 2 != 0;
|
||||
let mut p = pos + 1;
|
||||
let (mut temporal_id, mut spatial_id) = (0, 0);
|
||||
if ext {
|
||||
let e = *data.get(p).ok_or(Error::Truncated)?;
|
||||
temporal_id = (e >> 5) as u32;
|
||||
spatial_id = ((e >> 3) & 3) as u32;
|
||||
p += 1;
|
||||
}
|
||||
let size = if has_size {
|
||||
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
|
||||
p += n;
|
||||
v as usize
|
||||
} else {
|
||||
data.len().checked_sub(p).ok_or(Error::Truncated)?
|
||||
};
|
||||
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
|
||||
Ok(ObuUnit { obu_type, ext, temporal_id, spatial_id, payload: &data[p..end], end })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guard_tests {
|
||||
use super::*;
|
||||
|
||||
+313
-167
@@ -55,21 +55,79 @@ impl Rng {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
|
||||
let _ = seq;
|
||||
let bd = pic.bit_depth as u32;
|
||||
let mono = pic.mono_chrome;
|
||||
let num_planes = if mono { 1 } else { 3 };
|
||||
let (sub_x, sub_y) = (pic.subsampling_x as usize, pic.subsampling_y as usize);
|
||||
let w = pic.width as usize;
|
||||
let h = pic.height as usize;
|
||||
let grain_center = 128i32 << (bd - 8);
|
||||
let grain_min = -grain_center;
|
||||
let grain_max = (256i32 << (bd - 8)) - 1 - grain_center;
|
||||
/// Picture-level parameters shared by the grain synthesis stages.
|
||||
struct GrainCtx<'a> {
|
||||
g: &'a FilmGrainParams,
|
||||
bd: u32,
|
||||
mono: bool,
|
||||
sub_x: usize,
|
||||
sub_y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
grain_min: i32,
|
||||
grain_max: i32,
|
||||
}
|
||||
|
||||
// Generate grain (7.18.3.3).
|
||||
let mut rng = Rng(g.grain_seed);
|
||||
let shift = 12 - bd + g.grain_scale_shift;
|
||||
impl GrainCtx<'_> {
|
||||
fn num_planes(&self) -> usize {
|
||||
if self.mono { 1 } else { 3 }
|
||||
}
|
||||
|
||||
fn grain_shift(&self) -> u32 {
|
||||
12 - self.bd + self.g.grain_scale_shift
|
||||
}
|
||||
|
||||
fn ar_shift(&self) -> u32 {
|
||||
self.g.ar_coeff_shift_minus_6 + 6
|
||||
}
|
||||
|
||||
fn lag(&self) -> i32 {
|
||||
self.g.ar_coeff_lag as i32
|
||||
}
|
||||
|
||||
fn stripe_w(&self) -> usize {
|
||||
self.w + 64
|
||||
}
|
||||
}
|
||||
|
||||
type GrainTemplate = Vec<[i32; 82]>;
|
||||
|
||||
/// The luma, Cb and Cr grain templates (7.18.3.3).
|
||||
struct Templates {
|
||||
luma: GrainTemplate,
|
||||
cb: GrainTemplate,
|
||||
cr: GrainTemplate,
|
||||
}
|
||||
|
||||
impl Templates {
|
||||
fn plane(&self, plane: usize) -> &GrainTemplate {
|
||||
match plane {
|
||||
0 => &self.luma,
|
||||
1 => &self.cb,
|
||||
_ => &self.cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn luma_ar_sum(g: &FilmGrainParams, luma: &[[i32; 82]], lag: i32, y: usize, x: usize) -> i32 {
|
||||
let mut s = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
if dr == 0 && dc == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
|
||||
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn luma_grain(c: &GrainCtx, rng: &mut Rng) -> GrainTemplate {
|
||||
let g = c.g;
|
||||
let shift = c.grain_shift();
|
||||
let mut luma = vec![[0i32; 82]; 73];
|
||||
for row in luma.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
@@ -77,77 +135,89 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
let ar_shift = g.ar_coeff_shift_minus_6 + 6;
|
||||
let lag = g.ar_coeff_lag as i32;
|
||||
let ar_shift = c.ar_shift();
|
||||
let lag = c.lag();
|
||||
for y in 3..73 {
|
||||
for x in 3..82 - 3 {
|
||||
let mut s = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
if dr == 0 && dc == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
|
||||
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(grain_min, grain_max);
|
||||
}
|
||||
}
|
||||
let chroma_w = if sub_x != 0 { 44 } else { 82 };
|
||||
let chroma_h = if sub_y != 0 { 38 } else { 73 };
|
||||
let mut cb = vec![[0i32; 82]; 73];
|
||||
let mut cr = vec![[0i32; 82]; 73];
|
||||
if !mono {
|
||||
for (arr, seed, on) in [(&mut cb, 0xb524u32, g.num_cb_points > 0), (&mut cr, 0x49d8, g.num_cr_points > 0)] {
|
||||
rng.0 = g.grain_seed ^ seed;
|
||||
for row in arr.iter_mut().take(chroma_h) {
|
||||
for v in row.iter_mut().take(chroma_w) {
|
||||
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 3..chroma_h {
|
||||
for x in 3..chroma_w - 3 {
|
||||
let mut s0 = 0;
|
||||
let mut s1 = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
|
||||
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
|
||||
if dr == 0 && dc == 0 {
|
||||
if g.num_y_points > 0 {
|
||||
let mut l = 0;
|
||||
let lx = ((x - 3) << sub_x) + 3;
|
||||
let ly = ((y - 3) << sub_y) + 3;
|
||||
for i in 0..=sub_y {
|
||||
for j in 0..=sub_x {
|
||||
l += luma[ly + i][lx + j];
|
||||
}
|
||||
}
|
||||
let l = round2(l, (sub_x + sub_y) as u32);
|
||||
s0 += l * c0;
|
||||
s1 += l * c1;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
|
||||
s0 += cb[yy][xx] * c0;
|
||||
s1 += cr[yy][xx] * c1;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
cb[y][x] = (cb[y][x] + round2(s0, ar_shift)).clamp(grain_min, grain_max);
|
||||
cr[y][x] = (cr[y][x] + round2(s1, ar_shift)).clamp(grain_min, grain_max);
|
||||
}
|
||||
let s = luma_ar_sum(g, &luma, lag, y, x);
|
||||
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
}
|
||||
luma
|
||||
}
|
||||
|
||||
// Scaling lookup (7.18.3.4).
|
||||
/// The luma contribution to the chroma autoregressive filter at chroma position (y, x).
|
||||
fn chroma_luma_term(c: &GrainCtx, luma: &[[i32; 82]], y: usize, x: usize) -> i32 {
|
||||
let mut l = 0;
|
||||
let lx = ((x - 3) << c.sub_x) + 3;
|
||||
let ly = ((y - 3) << c.sub_y) + 3;
|
||||
for i in 0..=c.sub_y {
|
||||
for j in 0..=c.sub_x {
|
||||
l += luma[ly + i][lx + j];
|
||||
}
|
||||
}
|
||||
round2(l, (c.sub_x + c.sub_y) as u32)
|
||||
}
|
||||
|
||||
fn chroma_ar_sums(c: &GrainCtx, t: &Templates, y: usize, x: usize) -> (i32, i32) {
|
||||
let g = c.g;
|
||||
let lag = c.lag();
|
||||
let mut s0 = 0;
|
||||
let mut s1 = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
|
||||
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
|
||||
if dr == 0 && dc == 0 {
|
||||
if g.num_y_points > 0 {
|
||||
let l = chroma_luma_term(c, &t.luma, y, x);
|
||||
s0 += l * c0;
|
||||
s1 += l * c1;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
|
||||
s0 += t.cb[yy][xx] * c0;
|
||||
s1 += t.cr[yy][xx] * c1;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
(s0, s1)
|
||||
}
|
||||
|
||||
/// Chroma grain templates; zero for monochrome pictures.
|
||||
fn chroma_grain(c: &GrainCtx, rng: &mut Rng, luma: GrainTemplate) -> Templates {
|
||||
let g = c.g;
|
||||
let mut t = Templates { luma, cb: vec![[0i32; 82]; 73], cr: vec![[0i32; 82]; 73] };
|
||||
if c.mono {
|
||||
return t;
|
||||
}
|
||||
let shift = c.grain_shift();
|
||||
let ar_shift = c.ar_shift();
|
||||
let chroma_w = if c.sub_x != 0 { 44 } else { 82 };
|
||||
let chroma_h = if c.sub_y != 0 { 38 } else { 73 };
|
||||
for (arr, seed, on) in [(&mut t.cb, 0xb524u32, g.num_cb_points > 0), (&mut t.cr, 0x49d8, g.num_cr_points > 0)] {
|
||||
rng.0 = g.grain_seed ^ seed;
|
||||
for row in arr.iter_mut().take(chroma_h) {
|
||||
for v in row.iter_mut().take(chroma_w) {
|
||||
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 3..chroma_h {
|
||||
for x in 3..chroma_w - 3 {
|
||||
let (s0, s1) = chroma_ar_sums(c, &t, y, x);
|
||||
t.cb[y][x] = (t.cb[y][x] + round2(s0, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
t.cr[y][x] = (t.cr[y][x] + round2(s1, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Scaling lookup tables (7.18.3.4), one per plane.
|
||||
fn scaling_luts(g: &FilmGrainParams, num_planes: usize) -> [[i32; 256]; 3] {
|
||||
let mut lut = [[0i32; 256]; 3];
|
||||
for (plane, l) in lut.iter_mut().enumerate().take(num_planes) {
|
||||
let (n, xs, ys) = if plane == 0 || g.chroma_scaling_from_luma {
|
||||
@@ -159,101 +229,141 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
};
|
||||
*l = scaling_lut(n, xs, ys);
|
||||
}
|
||||
let scale_lut = |plane: usize, index: i32| -> i32 {
|
||||
let sh = bd - 8;
|
||||
let x = index >> sh;
|
||||
let rem = index - (x << sh);
|
||||
if bd == 8 || x == 255 {
|
||||
lut[plane][x as usize]
|
||||
} else {
|
||||
let start = lut[plane][x as usize];
|
||||
let end = lut[plane][x as usize + 1];
|
||||
start + round2((end - start) * rem, sh)
|
||||
}
|
||||
};
|
||||
lut
|
||||
}
|
||||
|
||||
// Noise stripes (7.18.3.5).
|
||||
let stripe_w = w + 64;
|
||||
let n_stripes = (h.div_ceil(2)).div_ceil(16).max(1);
|
||||
let mut stripes: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
|
||||
let overlap = g.overlap_flag;
|
||||
for (luma_num, stripe) in stripes.iter_mut().enumerate() {
|
||||
let mut r = g.grain_seed;
|
||||
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
|
||||
r ^= ((luma_num * 173 + 105) & 255) as u32;
|
||||
rng.0 = r;
|
||||
let mut x = 0;
|
||||
while x < w.div_ceil(2) {
|
||||
let rand = rng.next(8) as usize;
|
||||
let off_x = rand >> 4;
|
||||
let off_y = rand & 15;
|
||||
for (plane, s) in stripe.iter_mut().enumerate().take(num_planes) {
|
||||
let psx = if plane > 0 { sub_x } else { 0 };
|
||||
let psy = if plane > 0 { sub_y } else { 0 };
|
||||
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
|
||||
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
|
||||
let src = match plane {
|
||||
0 => &luma,
|
||||
1 => &cb,
|
||||
_ => &cr,
|
||||
fn scale_lookup(lut: &[[i32; 256]; 3], bd: u32, plane: usize, index: i32) -> i32 {
|
||||
let sh = bd - 8;
|
||||
let x = index >> sh;
|
||||
let rem = index - (x << sh);
|
||||
if bd == 8 || x == 255 {
|
||||
lut[plane][x as usize]
|
||||
} else {
|
||||
let start = lut[plane][x as usize];
|
||||
let end = lut[plane][x as usize + 1];
|
||||
start + round2((end - start) * rem, sh)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills one plane of a noise stripe from the grain template at stripe column `x`.
|
||||
fn fill_stripe_plane(c: &GrainCtx, src: &GrainTemplate, s: &mut [i32], plane: usize, offsets: (usize, usize), x: usize) {
|
||||
let (off_x, off_y) = offsets;
|
||||
let stripe_w = c.stripe_w();
|
||||
let overlap = c.g.overlap_flag;
|
||||
let psx = if plane > 0 { c.sub_x } else { 0 };
|
||||
let psy = if plane > 0 { c.sub_y } else { 0 };
|
||||
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
|
||||
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
|
||||
for i in 0..(34 >> psy) {
|
||||
for j in 0..(34 >> psx) {
|
||||
let mut gv = src[poy + i][pox + j];
|
||||
let (idx, blend) = if psx == 0 { (i * stripe_w + x * 2 + j, j < 2) } else { (i * stripe_w + x + j, j == 0) };
|
||||
if blend && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = if psx != 0 {
|
||||
old * 23 + gv * 22
|
||||
} else if j == 0 {
|
||||
old * 27 + gv * 17
|
||||
} else {
|
||||
old * 17 + gv * 27
|
||||
};
|
||||
for i in 0..(34 >> psy) {
|
||||
for j in 0..(34 >> psx) {
|
||||
let mut gv = src[poy + i][pox + j];
|
||||
if psx == 0 {
|
||||
let idx = i * stripe_w + x * 2 + j;
|
||||
if j < 2 && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = if j == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
|
||||
gv = round2(gv, 5).clamp(grain_min, grain_max);
|
||||
}
|
||||
s[idx] = gv;
|
||||
} else {
|
||||
let idx = i * stripe_w + x + j;
|
||||
if j == 0 && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
|
||||
}
|
||||
s[idx] = gv;
|
||||
}
|
||||
}
|
||||
}
|
||||
gv = round2(gv, 5).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
x += 16;
|
||||
s[idx] = gv;
|
||||
}
|
||||
}
|
||||
let noise_at = |plane: usize, y: usize, x: usize| -> i32 {
|
||||
let psy = if plane > 0 { sub_y } else { 0 };
|
||||
}
|
||||
|
||||
/// Noise stripes (7.18.3.5).
|
||||
struct Stripes {
|
||||
data: Vec<[Vec<i32>; 3]>,
|
||||
stripe_w: usize,
|
||||
overlap: bool,
|
||||
sub_y: usize,
|
||||
grain_min: i32,
|
||||
grain_max: i32,
|
||||
}
|
||||
|
||||
impl Stripes {
|
||||
fn build(c: &GrainCtx, t: &Templates, rng: &mut Rng) -> Stripes {
|
||||
let g = c.g;
|
||||
let stripe_w = c.stripe_w();
|
||||
let n_stripes = (c.h.div_ceil(2)).div_ceil(16).max(1);
|
||||
let mut data: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
|
||||
for (luma_num, stripe) in data.iter_mut().enumerate() {
|
||||
let mut r = g.grain_seed;
|
||||
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
|
||||
r ^= ((luma_num * 173 + 105) & 255) as u32;
|
||||
rng.0 = r;
|
||||
let mut x = 0;
|
||||
while x < c.w.div_ceil(2) {
|
||||
let rand = rng.next(8) as usize;
|
||||
let offsets = (rand >> 4, rand & 15);
|
||||
for (plane, s) in stripe.iter_mut().enumerate().take(c.num_planes()) {
|
||||
fill_stripe_plane(c, t.plane(plane), s, plane, offsets, x);
|
||||
}
|
||||
x += 16;
|
||||
}
|
||||
}
|
||||
Stripes { data, stripe_w, overlap: g.overlap_flag, sub_y: c.sub_y, grain_min: c.grain_min, grain_max: c.grain_max }
|
||||
}
|
||||
|
||||
fn noise_at(&self, plane: usize, y: usize, x: usize) -> i32 {
|
||||
let psy = if plane > 0 { self.sub_y } else { 0 };
|
||||
let luma_num = y >> (5 - psy);
|
||||
let i = y - (luma_num << (5 - psy));
|
||||
let mut gv = stripes[luma_num][plane][i * stripe_w + x];
|
||||
let mut gv = self.data[luma_num][plane][i * self.stripe_w + x];
|
||||
if psy == 0 {
|
||||
if i < 2 && luma_num > 0 && overlap {
|
||||
let old = stripes[luma_num - 1][plane][(i + 32) * stripe_w + x];
|
||||
if i < 2 && luma_num > 0 && self.overlap {
|
||||
let old = self.data[luma_num - 1][plane][(i + 32) * self.stripe_w + x];
|
||||
gv = if i == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
|
||||
gv = round2(gv, 5).clamp(grain_min, grain_max);
|
||||
gv = round2(gv, 5).clamp(self.grain_min, self.grain_max);
|
||||
}
|
||||
} else if i < 1 && luma_num > 0 && overlap {
|
||||
let old = stripes[luma_num - 1][plane][(i + 16) * stripe_w + x];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
|
||||
} else if i < 1 && luma_num > 0 && self.overlap {
|
||||
let old = self.data[luma_num - 1][plane][(i + 16) * self.stripe_w + x];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(self.grain_min, self.grain_max);
|
||||
}
|
||||
gv
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Blend (7.18.3.5).
|
||||
let (min_value, max_luma, max_chroma) = if g.clip_to_restricted_range {
|
||||
let ml = 235 << (bd - 8);
|
||||
(16 << (bd - 8), ml, if pic.matrix_coefficients as usize == MC_IDENTITY { ml } else { 240 << (bd - 8) })
|
||||
} else {
|
||||
let m = (256 << (bd - 8)) - 1;
|
||||
(0, m, m)
|
||||
};
|
||||
let pix_max = (1i32 << bd) - 1;
|
||||
let scaling_shift = g.grain_scaling_minus_8 + 8;
|
||||
if !mono {
|
||||
/// Output clipping range and scaling shift for the blend step (7.18.3.5).
|
||||
struct BlendRange {
|
||||
min_value: i32,
|
||||
max_luma: i32,
|
||||
max_chroma: i32,
|
||||
pix_max: i32,
|
||||
scaling_shift: u32,
|
||||
}
|
||||
|
||||
impl BlendRange {
|
||||
fn new(c: &GrainCtx, matrix_coefficients: usize) -> BlendRange {
|
||||
let bd = c.bd;
|
||||
let (min_value, max_luma, max_chroma) = if c.g.clip_to_restricted_range {
|
||||
let ml = 235 << (bd - 8);
|
||||
(16 << (bd - 8), ml, if matrix_coefficients == MC_IDENTITY { ml } else { 240 << (bd - 8) })
|
||||
} else {
|
||||
let m = (256 << (bd - 8)) - 1;
|
||||
(0, m, m)
|
||||
};
|
||||
BlendRange { min_value, max_luma, max_chroma, pix_max: (1i32 << bd) - 1, scaling_shift: c.g.grain_scaling_minus_8 + 8 }
|
||||
}
|
||||
}
|
||||
|
||||
struct BlendEnv<'a> {
|
||||
c: &'a GrainCtx<'a>,
|
||||
lut: &'a [[i32; 256]; 3],
|
||||
stripes: &'a Stripes,
|
||||
range: &'a BlendRange,
|
||||
}
|
||||
|
||||
impl BlendEnv<'_> {
|
||||
fn blend_chroma(&self, py: &[u16], pu: &mut [u16], pv: &mut [u16]) {
|
||||
let c = self.c;
|
||||
let g = c.g;
|
||||
let (w, h, sub_x, sub_y, bd) = (c.w, c.h, c.sub_x, c.sub_y, c.bd);
|
||||
let cw = (w + sub_x) >> sub_x;
|
||||
let ch = (h + sub_y) >> sub_y;
|
||||
let [py, pu, pv] = &mut pic.planes;
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let lx = x << sub_x;
|
||||
@@ -272,26 +382,62 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
avg
|
||||
} else {
|
||||
let combined = avg * (luma_mult as i32 - 128) + orig * (mult as i32 - 128);
|
||||
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, pix_max)
|
||||
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, self.range.pix_max)
|
||||
};
|
||||
let noise = round2(scale_lut(plane, merged) * noise_at(plane, y, x), scaling_shift);
|
||||
out[y * cw + x] = (orig + noise).clamp(min_value, max_chroma) as u16;
|
||||
let noise = round2(scale_lookup(self.lut, bd, plane, merged) * self.stripes.noise_at(plane, y, x), self.range.scaling_shift);
|
||||
out[y * cw + x] = (orig + noise).clamp(self.range.min_value, self.range.max_chroma) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.num_y_points > 0 {
|
||||
let py = &mut pic.planes[0];
|
||||
|
||||
fn blend_luma(&self, py: &mut [u16]) {
|
||||
let (w, h) = (self.c.w, self.c.h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let orig = py[y * w + x] as i32;
|
||||
let noise = round2(scale_lut(0, orig) * noise_at(0, y, x), scaling_shift);
|
||||
py[y * w + x] = (orig + noise).clamp(min_value, max_luma) as u16;
|
||||
let noise = round2(scale_lookup(self.lut, self.c.bd, 0, orig) * self.stripes.noise_at(0, y, x), self.range.scaling_shift);
|
||||
py[y * w + x] = (orig + noise).clamp(self.range.min_value, self.range.max_luma) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
|
||||
let _ = seq;
|
||||
let bd = pic.bit_depth as u32;
|
||||
let grain_center = 128i32 << (bd - 8);
|
||||
let c = GrainCtx {
|
||||
g,
|
||||
bd,
|
||||
mono: pic.mono_chrome,
|
||||
sub_x: pic.subsampling_x as usize,
|
||||
sub_y: pic.subsampling_y as usize,
|
||||
w: pic.width as usize,
|
||||
h: pic.height as usize,
|
||||
grain_min: -grain_center,
|
||||
grain_max: (256i32 << (bd - 8)) - 1 - grain_center,
|
||||
};
|
||||
|
||||
// Generate grain (7.18.3.3).
|
||||
let mut rng = Rng(g.grain_seed);
|
||||
let luma = luma_grain(&c, &mut rng);
|
||||
let templates = chroma_grain(&c, &mut rng, luma);
|
||||
let lut = scaling_luts(g, c.num_planes());
|
||||
let stripes = Stripes::build(&c, &templates, &mut rng);
|
||||
|
||||
// Blend (7.18.3.5).
|
||||
let range = BlendRange::new(&c, pic.matrix_coefficients as usize);
|
||||
let env = BlendEnv { c: &c, lut: &lut, stripes: &stripes, range: &range };
|
||||
if !c.mono {
|
||||
let [py, pu, pv] = &mut pic.planes;
|
||||
env.blend_chroma(py, pu, pv);
|
||||
}
|
||||
if g.num_y_points > 0 {
|
||||
env.blend_luma(&mut pic.planes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+224
-178
@@ -429,6 +429,38 @@ pub struct FrameHeader {
|
||||
pub spatial_id: u32,
|
||||
}
|
||||
|
||||
/// mark_ref_frames( idLen ): invalidates reference slots whose frame id is too far from the current one.
|
||||
fn mark_ref_frames(st: &mut HeaderState, cur: u32, id_len: u32) {
|
||||
let diff_len = st.seq.delta_frame_id_length_minus_2 + 2;
|
||||
for rs in st.refs.iter_mut() {
|
||||
if cur > (1 << diff_len) {
|
||||
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips buffer_removal_time for every operating point that includes this layer.
|
||||
fn skip_buffer_removal_times(r: &mut BitReader, seq: &SequenceHeader, temporal_id: u32, spatial_id: u32) -> Result<()> {
|
||||
if !(seq.decoder_model_info_present && r.flag()?) {
|
||||
return Ok(());
|
||||
}
|
||||
for op in 0..=seq.operating_points_cnt_minus_1 {
|
||||
if seq.decoder_model_present_for_this_op[op] {
|
||||
let idc = seq.operating_point_idc[op];
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if idc == 0 || (in_t == 1 && in_s == 1) {
|
||||
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
pub fn get_relative_dist(&self, seq: &SequenceHeader, a: u32, b: u32) -> i32 {
|
||||
relative_dist(seq, a, b)
|
||||
@@ -494,35 +526,10 @@ impl FrameHeader {
|
||||
} else {
|
||||
h.show_existing_frame = r.flag()?;
|
||||
if h.show_existing_frame {
|
||||
h.frame_to_show_map_idx = r.f(3)? as usize;
|
||||
if seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(id_len)?;
|
||||
}
|
||||
let rs = &st.refs[h.frame_to_show_map_idx];
|
||||
if !rs.valid {
|
||||
return Err(Error::Invalid("show_existing_frame of an empty slot"));
|
||||
}
|
||||
h.frame_type = rs.frame_type;
|
||||
if h.frame_type == KEY_FRAME as u8 {
|
||||
h.refresh_frame_flags = all_frames;
|
||||
}
|
||||
if seq.film_grain_params_present {
|
||||
h.film_grain = rs.grain.clone();
|
||||
}
|
||||
h.parse_show_existing_frame(r, st, id_len)?;
|
||||
return Ok(h);
|
||||
}
|
||||
h.frame_type = r.f(2)? as u8;
|
||||
h.frame_is_intra = h.frame_type == INTRA_ONLY_FRAME as u8 || h.frame_type == KEY_FRAME as u8;
|
||||
h.show_frame = r.flag()?;
|
||||
if h.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
h.showable_frame = if h.show_frame { h.frame_type != KEY_FRAME as u8 } else { r.flag()? };
|
||||
h.error_resilient_mode =
|
||||
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { true } else { r.flag()? };
|
||||
h.parse_frame_type_and_flags(r, seq)?;
|
||||
}
|
||||
if h.frame_type == KEY_FRAME as u8 && h.show_frame {
|
||||
for rs in st.refs.iter_mut() {
|
||||
@@ -533,33 +540,11 @@ impl FrameHeader {
|
||||
h.order_hints[LAST_FRAME + i] = 0;
|
||||
}
|
||||
}
|
||||
h.disable_cdf_update = r.flag()?;
|
||||
h.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
|
||||
r.flag()?
|
||||
} else {
|
||||
seq.seq_force_screen_content_tools != 0
|
||||
};
|
||||
if h.allow_screen_content_tools {
|
||||
h.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
|
||||
}
|
||||
if h.frame_is_intra {
|
||||
h.force_integer_mv = true;
|
||||
}
|
||||
h.parse_screen_content_flags(r, seq)?;
|
||||
if seq.frame_id_numbers_present {
|
||||
h.current_frame_id = r.f(id_len)?;
|
||||
// mark_ref_frames( idLen )
|
||||
let diff_len = seq.delta_frame_id_length_minus_2 + 2;
|
||||
let cur = h.current_frame_id;
|
||||
for rs in st.refs.iter_mut() {
|
||||
if cur > (1 << diff_len) {
|
||||
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
}
|
||||
st.current_frame_id = cur;
|
||||
mark_ref_frames(st, h.current_frame_id, id_len);
|
||||
st.current_frame_id = h.current_frame_id;
|
||||
}
|
||||
h.frame_size_override_flag = if h.frame_type == SWITCH_FRAME as u8 {
|
||||
true
|
||||
@@ -570,18 +555,7 @@ impl FrameHeader {
|
||||
};
|
||||
h.order_hint = r.f(seq.order_hint_bits)?;
|
||||
h.primary_ref_frame = if h.frame_is_intra || h.error_resilient_mode { PRIMARY_REF_NONE } else { r.f(3)? as usize };
|
||||
if seq.decoder_model_info_present && r.flag()? {
|
||||
for op in 0..=seq.operating_points_cnt_minus_1 {
|
||||
if seq.decoder_model_present_for_this_op[op] {
|
||||
let idc = seq.operating_point_idc[op];
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if idc == 0 || (in_t == 1 && in_s == 1) {
|
||||
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
skip_buffer_removal_times(r, seq, temporal_id, spatial_id)?;
|
||||
h.refresh_frame_flags =
|
||||
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { all_frames } else { r.f(8)? };
|
||||
if (!h.frame_is_intra || h.refresh_frame_flags != all_frames) && h.error_resilient_mode && seq.enable_order_hint {
|
||||
@@ -599,126 +573,16 @@ impl FrameHeader {
|
||||
h.allow_intrabc = r.flag()?;
|
||||
}
|
||||
} else {
|
||||
let mut short = false;
|
||||
if seq.enable_order_hint {
|
||||
short = r.flag()?;
|
||||
if short {
|
||||
let last = r.f(3)? as usize;
|
||||
let gold = r.f(3)? as usize;
|
||||
h.set_frame_refs(seq, st.refs, last, gold);
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !short {
|
||||
h.ref_frame_idx[i] = r.f(3)? as usize;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !st.refs[h.ref_frame_idx[i]].valid {
|
||||
return Err(Error::Invalid("reference to an empty slot"));
|
||||
}
|
||||
}
|
||||
if h.frame_size_override_flag && !h.error_resilient_mode {
|
||||
// frame_size_with_refs( )
|
||||
let mut found = false;
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if r.flag()? {
|
||||
let rs = &st.refs[h.ref_frame_idx[i]];
|
||||
h.upscaled_width = rs.upscaled_width;
|
||||
h.frame_width = h.upscaled_width;
|
||||
h.frame_height = rs.frame_height;
|
||||
h.render_width = rs.render_width;
|
||||
h.render_height = rs.render_height;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
h.frame_size(r, seq)?;
|
||||
h.render_size(r)?;
|
||||
} else {
|
||||
h.superres_params(r, seq)?;
|
||||
h.compute_image_size();
|
||||
}
|
||||
} else {
|
||||
h.frame_size(r, seq)?;
|
||||
h.render_size(r)?;
|
||||
}
|
||||
h.allow_high_precision_mv = if h.force_integer_mv { false } else { r.flag()? };
|
||||
h.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
|
||||
h.is_motion_mode_switchable = r.flag()?;
|
||||
h.use_ref_frame_mvs = if h.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
let rf = LAST_FRAME + i;
|
||||
let hint = st.refs[h.ref_frame_idx[i]].order_hint;
|
||||
h.order_hints[rf] = hint;
|
||||
h.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, h.order_hint) > 0;
|
||||
}
|
||||
h.parse_inter_frame_refs(r, st)?;
|
||||
}
|
||||
h.disable_frame_end_update_cdf = if seq.reduced_still_picture_header || h.disable_cdf_update { true } else { r.flag()? };
|
||||
if h.primary_ref_frame == PRIMARY_REF_NONE {
|
||||
// setup_past_independence( )
|
||||
st.seg_features = SegmentationFeatures::default();
|
||||
st.prev_gm_params = default_gm_params();
|
||||
st.lf_deltas = LoopFilterDeltas::defaults();
|
||||
h.lf.delta_enabled = true;
|
||||
} else {
|
||||
// load_previous( )
|
||||
let prev = &st.refs[h.ref_frame_idx[h.primary_ref_frame]];
|
||||
st.prev_gm_params = prev.gm_params;
|
||||
st.lf_deltas = prev.lf_deltas;
|
||||
st.seg_features = prev.seg_features;
|
||||
}
|
||||
h.setup_or_load_previous(st);
|
||||
h.prev_gm_params = st.prev_gm_params;
|
||||
h.tile_info(r, seq)?;
|
||||
h.quantization_params(r, seq)?;
|
||||
h.segmentation_params(r, st)?;
|
||||
// delta_q_params( )
|
||||
if h.quant.base_q_idx > 0 {
|
||||
h.delta_q_present = r.flag()?;
|
||||
}
|
||||
if h.delta_q_present {
|
||||
h.delta_q_res = r.f(2)?;
|
||||
}
|
||||
// delta_lf_params( )
|
||||
if h.delta_q_present {
|
||||
if !h.allow_intrabc {
|
||||
h.delta_lf_present = r.flag()?;
|
||||
}
|
||||
if h.delta_lf_present {
|
||||
h.delta_lf_res = r.f(2)?;
|
||||
h.delta_lf_multi = r.flag()?;
|
||||
}
|
||||
}
|
||||
h.coded_lossless = true;
|
||||
for seg_id in 0..MAX_SEGMENTS {
|
||||
let q = h.get_qindex(true, seg_id, 0);
|
||||
let ll = q == 0
|
||||
&& h.quant.delta_q_y_dc == 0
|
||||
&& h.quant.delta_q_u_ac == 0
|
||||
&& h.quant.delta_q_u_dc == 0
|
||||
&& h.quant.delta_q_v_ac == 0
|
||||
&& h.quant.delta_q_v_dc == 0;
|
||||
h.lossless_array[seg_id] = ll;
|
||||
if !ll {
|
||||
h.coded_lossless = false;
|
||||
}
|
||||
if h.quant.using_qmatrix {
|
||||
if ll {
|
||||
h.seg_qm_level[0][seg_id] = 15;
|
||||
h.seg_qm_level[1][seg_id] = 15;
|
||||
h.seg_qm_level[2][seg_id] = 15;
|
||||
} else {
|
||||
h.seg_qm_level[0][seg_id] = h.quant.qm_y;
|
||||
h.seg_qm_level[1][seg_id] = h.quant.qm_u;
|
||||
h.seg_qm_level[2][seg_id] = h.quant.qm_v;
|
||||
}
|
||||
}
|
||||
}
|
||||
h.all_lossless = h.coded_lossless && h.frame_width == h.upscaled_width;
|
||||
h.delta_q_lf_params(r)?;
|
||||
h.compute_lossless();
|
||||
h.loop_filter_params(r, seq, st)?;
|
||||
h.cdef_params(r, seq)?;
|
||||
h.lr_params(r, seq)?;
|
||||
@@ -739,6 +603,182 @@ impl FrameHeader {
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The show_existing_frame branch: picks the slot to show and inherits its frame type and grain.
|
||||
fn parse_show_existing_frame(&mut self, r: &mut BitReader, st: &HeaderState, id_len: u32) -> Result<()> {
|
||||
let seq = st.seq;
|
||||
self.frame_to_show_map_idx = r.f(3)? as usize;
|
||||
if seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(id_len)?;
|
||||
}
|
||||
let rs = &st.refs[self.frame_to_show_map_idx];
|
||||
if !rs.valid {
|
||||
return Err(Error::Invalid("show_existing_frame of an empty slot"));
|
||||
}
|
||||
self.frame_type = rs.frame_type;
|
||||
if self.frame_type == KEY_FRAME as u8 {
|
||||
self.refresh_frame_flags = (1u32 << NUM_REF_FRAMES) - 1;
|
||||
}
|
||||
if seq.film_grain_params_present {
|
||||
self.film_grain = rs.grain.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// frame_type, show_frame, showable_frame and error_resilient_mode.
|
||||
fn parse_frame_type_and_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.frame_type = r.f(2)? as u8;
|
||||
self.frame_is_intra = self.frame_type == INTRA_ONLY_FRAME as u8 || self.frame_type == KEY_FRAME as u8;
|
||||
self.show_frame = r.flag()?;
|
||||
if self.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
self.showable_frame = if self.show_frame { self.frame_type != KEY_FRAME as u8 } else { r.flag()? };
|
||||
self.error_resilient_mode =
|
||||
if self.frame_type == SWITCH_FRAME as u8 || (self.frame_type == KEY_FRAME as u8 && self.show_frame) { true } else { r.flag()? };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// disable_cdf_update, allow_screen_content_tools and force_integer_mv.
|
||||
fn parse_screen_content_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.disable_cdf_update = r.flag()?;
|
||||
self.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
|
||||
r.flag()?
|
||||
} else {
|
||||
seq.seq_force_screen_content_tools != 0
|
||||
};
|
||||
if self.allow_screen_content_tools {
|
||||
self.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
|
||||
}
|
||||
if self.frame_is_intra {
|
||||
self.force_integer_mv = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reference selection, frame size, motion vector precision and filter flags of an inter frame.
|
||||
fn parse_inter_frame_refs(&mut self, r: &mut BitReader, st: &HeaderState) -> Result<()> {
|
||||
let seq = st.seq;
|
||||
let mut short = false;
|
||||
if seq.enable_order_hint {
|
||||
short = r.flag()?;
|
||||
if short {
|
||||
let last = r.f(3)? as usize;
|
||||
let gold = r.f(3)? as usize;
|
||||
self.set_frame_refs(seq, st.refs, last, gold);
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !short {
|
||||
self.ref_frame_idx[i] = r.f(3)? as usize;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !st.refs[self.ref_frame_idx[i]].valid {
|
||||
return Err(Error::Invalid("reference to an empty slot"));
|
||||
}
|
||||
}
|
||||
if self.frame_size_override_flag && !self.error_resilient_mode {
|
||||
self.frame_size_with_refs(r, seq, st.refs)?;
|
||||
} else {
|
||||
self.frame_size(r, seq)?;
|
||||
self.render_size(r)?;
|
||||
}
|
||||
self.allow_high_precision_mv = if self.force_integer_mv { false } else { r.flag()? };
|
||||
self.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
|
||||
self.is_motion_mode_switchable = r.flag()?;
|
||||
self.use_ref_frame_mvs = if self.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
let rf = LAST_FRAME + i;
|
||||
let hint = st.refs[self.ref_frame_idx[i]].order_hint;
|
||||
self.order_hints[rf] = hint;
|
||||
self.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, self.order_hint) > 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// frame_size_with_refs( ).
|
||||
fn frame_size_with_refs(&mut self, r: &mut BitReader, seq: &SequenceHeader, refs: &[RefInfo; NUM_REF_FRAMES]) -> Result<()> {
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if r.flag()? {
|
||||
let rs = &refs[self.ref_frame_idx[i]];
|
||||
self.upscaled_width = rs.upscaled_width;
|
||||
self.frame_width = self.upscaled_width;
|
||||
self.frame_height = rs.frame_height;
|
||||
self.render_width = rs.render_width;
|
||||
self.render_height = rs.render_height;
|
||||
self.superres_params(r, seq)?;
|
||||
self.compute_image_size();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.frame_size(r, seq)?;
|
||||
self.render_size(r)
|
||||
}
|
||||
|
||||
/// setup_past_independence( ) or load_previous( ), depending on primary_ref_frame.
|
||||
fn setup_or_load_previous(&mut self, st: &mut HeaderState) {
|
||||
if self.primary_ref_frame == PRIMARY_REF_NONE {
|
||||
st.seg_features = SegmentationFeatures::default();
|
||||
st.prev_gm_params = default_gm_params();
|
||||
st.lf_deltas = LoopFilterDeltas::defaults();
|
||||
self.lf.delta_enabled = true;
|
||||
} else {
|
||||
let prev = &st.refs[self.ref_frame_idx[self.primary_ref_frame]];
|
||||
st.prev_gm_params = prev.gm_params;
|
||||
st.lf_deltas = prev.lf_deltas;
|
||||
st.seg_features = prev.seg_features;
|
||||
}
|
||||
}
|
||||
|
||||
/// delta_q_params( ) and delta_lf_params( ).
|
||||
fn delta_q_lf_params(&mut self, r: &mut BitReader) -> Result<()> {
|
||||
if self.quant.base_q_idx > 0 {
|
||||
self.delta_q_present = r.flag()?;
|
||||
}
|
||||
if self.delta_q_present {
|
||||
self.delta_q_res = r.f(2)?;
|
||||
if !self.allow_intrabc {
|
||||
self.delta_lf_present = r.flag()?;
|
||||
}
|
||||
if self.delta_lf_present {
|
||||
self.delta_lf_res = r.f(2)?;
|
||||
self.delta_lf_multi = r.flag()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-segment lossless flags, quantizer matrix levels and the frame-wide lossless flags.
|
||||
fn compute_lossless(&mut self) {
|
||||
self.coded_lossless = true;
|
||||
for seg_id in 0..MAX_SEGMENTS {
|
||||
let q = self.get_qindex(true, seg_id, 0);
|
||||
let ll = q == 0
|
||||
&& self.quant.delta_q_y_dc == 0
|
||||
&& self.quant.delta_q_u_ac == 0
|
||||
&& self.quant.delta_q_u_dc == 0
|
||||
&& self.quant.delta_q_v_ac == 0
|
||||
&& self.quant.delta_q_v_dc == 0;
|
||||
self.lossless_array[seg_id] = ll;
|
||||
if !ll {
|
||||
self.coded_lossless = false;
|
||||
}
|
||||
if self.quant.using_qmatrix {
|
||||
let levels = if ll { [15; 3] } else { [self.quant.qm_y, self.quant.qm_u, self.quant.qm_v] };
|
||||
for (plane, level) in levels.into_iter().enumerate() {
|
||||
self.seg_qm_level[plane][seg_id] = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.all_lossless = self.coded_lossless && self.frame_width == self.upscaled_width;
|
||||
}
|
||||
|
||||
fn superres_params(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.use_superres = if seq.enable_superres { r.flag()? } else { false };
|
||||
self.superres_denom = if self.use_superres { r.f(SUPERRES_DENOM_BITS as u32)? + SUPERRES_DENOM_MIN as u32 } else { SUPERRES_NUM as u32 };
|
||||
@@ -906,6 +946,9 @@ impl FrameHeader {
|
||||
let mut widest = 0;
|
||||
let mut start = 0;
|
||||
while start < sb_cols {
|
||||
if t.mi_col_starts.len() >= MAX_TILE_COLS {
|
||||
return Err(Error::Invalid("tile_cols exceeds MAX_TILE_COLS"));
|
||||
}
|
||||
t.mi_col_starts.push(start << sb_shift);
|
||||
let max_width = (sb_cols - start).min(max_tile_width_sb);
|
||||
let size = r.ns(max_width)? + 1;
|
||||
@@ -923,6 +966,9 @@ impl FrameHeader {
|
||||
let max_tile_height_sb = (max_tile_area_sb / widest.max(1)).max(1);
|
||||
let mut start = 0;
|
||||
while start < sb_rows {
|
||||
if t.mi_row_starts.len() >= MAX_TILE_ROWS {
|
||||
return Err(Error::Invalid("tile_rows exceeds MAX_TILE_ROWS"));
|
||||
}
|
||||
t.mi_row_starts.push(start << sb_shift);
|
||||
let max_height = (sb_rows - start).min(max_tile_height_sb);
|
||||
let size = r.ns(max_height)? + 1;
|
||||
|
||||
+60
-47
@@ -420,20 +420,8 @@ impl TileDecoder<'_, '_> {
|
||||
let fh = &self.fs.fh;
|
||||
for ref_list in 0..1 + is_compound as usize {
|
||||
let ref_frame = cand_refs[ref_list];
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 };
|
||||
let use_warp = if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let use_warp = self.warp_mode(ref_frame, ref_idx, w, h);
|
||||
let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32];
|
||||
if use_warp != 0 {
|
||||
let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] };
|
||||
@@ -448,40 +436,7 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
}
|
||||
let ct = self.b.compound_type;
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + rnd.2);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fill_blend_mask(plane, w, h, &preds, rnd.2);
|
||||
let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) };
|
||||
let max = (1i32 << bd) - 1;
|
||||
let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8;
|
||||
@@ -521,6 +476,64 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp selection for one reference: 0 none, 1 local warp, 2 global warp.
|
||||
fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 {
|
||||
let fh = &self.fs.fh;
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type.
|
||||
fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec<i32>; 2], round_post: u32) {
|
||||
let bd = self.fs.bit_depth;
|
||||
let ct = self.b.compound_type;
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + round_post);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_scaled_ref(&self, ref_idx: i32) -> bool {
|
||||
if ref_idx < 0 {
|
||||
return false;
|
||||
|
||||
+113
-78
@@ -73,57 +73,70 @@ impl LrCtx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Position and size of a (at most 4x4) restoration block within its plane.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LrBlock {
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
|
||||
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
|
||||
let fh = &fs.fh;
|
||||
let mut lr = cdef.clone();
|
||||
let up_w = fh.upscaled_width as i32;
|
||||
let fr_h = fh.frame_height as i32;
|
||||
let bd = fs.bit_depth;
|
||||
for plane in 0..fs.num_planes {
|
||||
let rtype_frame = fh.lr.frame_restoration_type[plane];
|
||||
if rtype_frame == RESTORE_NONE as u8 {
|
||||
if fs.fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = 0;
|
||||
while row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
restore_plane(fs, cur, cdef, &mut lr.planes[plane], plane);
|
||||
}
|
||||
lr
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn restore_plane(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, out: &mut Plane, plane: usize) {
|
||||
let fh = &fs.fh;
|
||||
let up_w = fh.upscaled_width as i32;
|
||||
let fr_h = fh.frame_height as i32;
|
||||
let bd = fs.bit_depth;
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = 0;
|
||||
while row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
let blk = LrBlock { x, y, w, h };
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, (plane, ui), blk, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, (plane, ui), blk, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let round0: u32 = if bd == 12 { 5 } else { 3 };
|
||||
let round1: u32 = if bd == 12 { 9 } else { 11 };
|
||||
let coef = &fs.lr.lr_wiener[plane][ui];
|
||||
@@ -166,13 +179,13 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
|
||||
let mut flt0 = [[0i32; 4]; 4];
|
||||
let mut flt1 = [[0i32; 4]; 4];
|
||||
box_filter(ctx, x, y, w, h, set, 0, bd, &mut flt0);
|
||||
box_filter(ctx, x, y, w, h, set, 1, bd, &mut flt1);
|
||||
box_filter(ctx, blk, (set, 0), bd, &mut flt0);
|
||||
box_filter(ctx, blk, (set, 1), bd, &mut flt1);
|
||||
let xqd = fs.lr.lr_sgr_xqd[plane][ui];
|
||||
let w0 = xqd[0] as i32;
|
||||
let w1 = xqd[1] as i32;
|
||||
@@ -198,8 +211,56 @@ fn round2_64(x: i64, n: u32) -> i64 {
|
||||
if n == 0 { x } else { (x + (1i64 << (n - 1))) >> n }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usize, bd: u32, f: &mut [[i32; 4]; 4]) {
|
||||
/// Box sums of the samples within `r` of (x, y): (sum of squares, sum).
|
||||
fn box_sums(ctx: &LrCtx, x: i32, y: i32, r: i32) -> (i64, i64) {
|
||||
let mut a = 0i64;
|
||||
let mut b = 0i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let c = ctx.sample(x + dx, y + dy) as i64;
|
||||
a += c * c;
|
||||
b += c;
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Self-guided filter constants of one pass.
|
||||
struct SgrConsts {
|
||||
n: i64,
|
||||
s: i64,
|
||||
one_over_n: i64,
|
||||
}
|
||||
|
||||
/// (A, B) coefficients of one sample from its box sums.
|
||||
fn sgr_coefficients(a: i64, b: i64, k: &SgrConsts, bd: u32) -> (i64, i64) {
|
||||
let a = round2_64(a, 2 * (bd - 8));
|
||||
let d = round2_64(b, bd - 8);
|
||||
let p = (a * k.n - d * d).max(0);
|
||||
let z = round2_64(p * k.s, SGRPROJ_MTABLE_BITS as u32);
|
||||
let a2 = if z >= 255 {
|
||||
256
|
||||
} else if z == 0 {
|
||||
1
|
||||
} else {
|
||||
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
|
||||
};
|
||||
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * k.one_over_n;
|
||||
(a2, round2_64(b2, SGRPROJ_RECIP_BITS as u32))
|
||||
}
|
||||
|
||||
fn box_weight(pass: usize, i: i32, dx: i32, dy: i32) -> i64 {
|
||||
if pass == 0 {
|
||||
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
|
||||
} else if dx == 0 || dy == 0 {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
fn box_filter(ctx: &LrCtx, blk: LrBlock, (set, pass): (usize, usize), bd: u32, f: &mut [[i32; 4]; 4]) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let r = SGR_PARAMS[set][pass * 2] as i32;
|
||||
if r == 0 {
|
||||
return;
|
||||
@@ -207,35 +268,15 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
|
||||
let eps = SGR_PARAMS[set][pass * 2 + 1] as i64;
|
||||
let n = ((2 * r + 1) * (2 * r + 1)) as i64;
|
||||
let n2e = n * n * eps;
|
||||
let s = ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e;
|
||||
let one_over_n = ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n;
|
||||
let k = SgrConsts { n, s: ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e, one_over_n: ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n };
|
||||
let mut a_arr = [[0i64; 6]; 6];
|
||||
let mut b_arr = [[0i64; 6]; 6];
|
||||
for i in -1..h + 1 {
|
||||
for j in -1..w + 1 {
|
||||
let mut a = 0i64;
|
||||
let mut b = 0i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let c = ctx.sample(x + j + dx, y + i + dy) as i64;
|
||||
a += c * c;
|
||||
b += c;
|
||||
}
|
||||
}
|
||||
let a = round2_64(a, 2 * (bd - 8));
|
||||
let d = round2_64(b, bd - 8);
|
||||
let p = (a * n - d * d).max(0);
|
||||
let z = round2_64(p * s, SGRPROJ_MTABLE_BITS as u32);
|
||||
let a2 = if z >= 255 {
|
||||
256
|
||||
} else if z == 0 {
|
||||
1
|
||||
} else {
|
||||
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
|
||||
};
|
||||
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * one_over_n;
|
||||
let (a, b) = box_sums(ctx, x + j, y + i, r);
|
||||
let (a2, b2) = sgr_coefficients(a, b, &k, bd);
|
||||
a_arr[(i + 1) as usize][(j + 1) as usize] = a2;
|
||||
b_arr[(i + 1) as usize][(j + 1) as usize] = round2_64(b2, SGRPROJ_RECIP_BITS as u32);
|
||||
b_arr[(i + 1) as usize][(j + 1) as usize] = b2;
|
||||
}
|
||||
}
|
||||
for i in 0..h {
|
||||
@@ -245,13 +286,7 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
|
||||
let mut b = 0i64;
|
||||
for dy in -1..=1i32 {
|
||||
for dx in -1..=1i32 {
|
||||
let weight = if pass == 0 {
|
||||
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
|
||||
} else if dx == 0 || dy == 0 {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let weight = box_weight(pass, i, dx, dy);
|
||||
a += weight * a_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
|
||||
b += weight * b_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
|
||||
}
|
||||
|
||||
+83
-64
@@ -1682,6 +1682,87 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
}
|
||||
}
|
||||
|
||||
/// eob_pt, eob_extra and the remaining eob bits.
|
||||
fn read_eob(&mut self, tx_sz: usize, tx_sz_ctx: usize, ptype: usize, tx_class: usize) -> usize {
|
||||
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
|
||||
let ectx = (tx_class != TX_CLASS_2D) as usize;
|
||||
let eob_pt = 1 + match eob_multisize {
|
||||
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
|
||||
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
|
||||
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
|
||||
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
|
||||
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
|
||||
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
|
||||
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
|
||||
};
|
||||
let mut eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
|
||||
let eob_shift = eob_pt as i32 - 3;
|
||||
if eob_shift >= 0 {
|
||||
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
|
||||
if extra == 1 {
|
||||
eob += 1 << eob_shift;
|
||||
}
|
||||
for i in 1..(eob_pt as i32 - 2).max(0) {
|
||||
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
eob += 1 << sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
eob
|
||||
}
|
||||
|
||||
/// Exp-Golomb coded remainder of a coefficient above the base range (read_golomb).
|
||||
fn read_golomb(&mut self) -> u32 {
|
||||
let mut length = 0;
|
||||
loop {
|
||||
length += 1;
|
||||
if self.sd.read_literal(1) == 1 || length > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut x: u32 = 1;
|
||||
for _ in (0..length - 1).rev() {
|
||||
x = (x << 1) | self.sd.read_literal(1);
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Reads the signs and Golomb remainders of the first `scan.len()` (= eob) coefficients, applies
|
||||
/// them to `self.quant`, and returns the culLevel and dcCategory contexts. `blk4` is (x4, y4, w4, h4).
|
||||
fn read_signs_and_golomb(&mut self, plane: usize, blk4: (usize, usize, usize, usize), scan: &[u16]) -> (u32, u8) {
|
||||
let (x4, y4, w4, h4) = blk4;
|
||||
let ptype = (plane > 0) as usize;
|
||||
let mut cul_level = 0u32;
|
||||
let mut dc_category = 0u8;
|
||||
for (c, &p) in scan.iter().enumerate() {
|
||||
let pos = p as usize;
|
||||
let sign = if self.quant[pos] != 0 {
|
||||
if c == 0 {
|
||||
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
|
||||
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
|
||||
} else {
|
||||
self.sd.read_literal(1) == 1
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
|
||||
let x = self.read_golomb();
|
||||
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
|
||||
}
|
||||
if pos == 0 && self.quant[pos] > 0 {
|
||||
dc_category = if sign { 1 } else { 2 };
|
||||
}
|
||||
self.quant[pos] &= 0xFFFFF;
|
||||
cul_level += self.quant[pos] as u32;
|
||||
if sign {
|
||||
self.quant[pos] = -self.quant[pos];
|
||||
}
|
||||
}
|
||||
(cul_level.min(63), dc_category)
|
||||
}
|
||||
|
||||
/// coeffs( ): returns eob.
|
||||
fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize) -> usize {
|
||||
let x4 = start_x >> 2;
|
||||
@@ -1707,32 +1788,8 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
}
|
||||
self.plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4);
|
||||
let scan = self.get_scan(tx_sz);
|
||||
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
|
||||
let tx_class = get_tx_class(self.plane_tx_type);
|
||||
let ectx = (tx_class != TX_CLASS_2D) as usize;
|
||||
let eob_pt = 1 + match eob_multisize {
|
||||
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
|
||||
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
|
||||
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
|
||||
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
|
||||
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
|
||||
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
|
||||
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
|
||||
};
|
||||
eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
|
||||
let eob_shift = eob_pt as i32 - 3;
|
||||
if eob_shift >= 0 {
|
||||
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
|
||||
if extra == 1 {
|
||||
eob += 1 << eob_shift;
|
||||
}
|
||||
for i in 1..(eob_pt as i32 - 2).max(0) {
|
||||
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
eob += 1 << sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
eob = self.read_eob(tx_sz, tx_sz_ctx, ptype, tx_class);
|
||||
let adj = ADJUSTED_TX_SIZE[tx_sz] as usize;
|
||||
let bwl = TX_WIDTH_LOG2[adj] as usize;
|
||||
let height = TX_HEIGHT[adj] as usize;
|
||||
@@ -1780,45 +1837,7 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
lev[li] = level as u8;
|
||||
}
|
||||
self.levels = lev;
|
||||
for c in 0..eob {
|
||||
let pos = scan[c] as usize;
|
||||
let sign = if self.quant[pos] != 0 {
|
||||
if c == 0 {
|
||||
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
|
||||
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
|
||||
} else {
|
||||
self.sd.read_literal(1) == 1
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
|
||||
let mut length = 0;
|
||||
loop {
|
||||
length += 1;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
break;
|
||||
}
|
||||
if length > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut x: u32 = 1;
|
||||
for _ in (0..length - 1).rev() {
|
||||
x = (x << 1) | self.sd.read_literal(1);
|
||||
}
|
||||
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
|
||||
}
|
||||
if pos == 0 && self.quant[pos] > 0 {
|
||||
dc_category = if sign { 1 } else { 2 };
|
||||
}
|
||||
self.quant[pos] &= 0xFFFFF;
|
||||
cul_level += self.quant[pos] as u32;
|
||||
if sign {
|
||||
self.quant[pos] = -self.quant[pos];
|
||||
}
|
||||
}
|
||||
cul_level = cul_level.min(63);
|
||||
(cul_level, dc_category) = self.read_signs_and_golomb(plane, (x4, y4, w4, h4), &scan[..eob]);
|
||||
}
|
||||
for i in 0..w4 {
|
||||
if x4 + i < self.t.above_level[plane].len() {
|
||||
|
||||
@@ -104,7 +104,7 @@ impl<const L: usize> Lane for [i32; L] {
|
||||
}
|
||||
#[inline(always)]
|
||||
fn neg(self) -> Self {
|
||||
self.map(|v| v.wrapping_neg())
|
||||
self.map(i32::wrapping_neg)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn mul_round12(self, m: i32) -> Self {
|
||||
@@ -165,6 +165,15 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..len {
|
||||
t[i] = copy[brev(n, i)];
|
||||
}
|
||||
inverse_dct_stage1(t, n, r);
|
||||
inverse_dct_stage2(t, n, r);
|
||||
inverse_dct_stage3(t, n, r);
|
||||
inverse_dct_stage4(t, n, r);
|
||||
inverse_dct_stage5(t, n, r);
|
||||
inverse_dct_stage6(t, n, r);
|
||||
}
|
||||
|
||||
fn inverse_dct_stage1<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n == 6 {
|
||||
for i in 0..16 {
|
||||
bf(t, 32 + i, 63 - i, 63 - 4 * brev(4, i) as i32, false);
|
||||
@@ -190,6 +199,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
hd(t, 16 + 2 * i, 17 + 2 * i, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage2<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n == 6 {
|
||||
for i in 0..4 {
|
||||
for j in 0..2 {
|
||||
@@ -221,6 +233,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage3<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
|
||||
}
|
||||
@@ -248,6 +263,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage4<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
hd(t, i, 3 - i, false, r);
|
||||
}
|
||||
@@ -273,6 +291,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage5<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n >= 3 {
|
||||
for i in 0..4 {
|
||||
hd(t, i, 7 - i, false, r);
|
||||
@@ -300,6 +321,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
hd(t, i, 15 - i, false, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage6<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n >= 5 {
|
||||
for i in 0..4 {
|
||||
bf(t, 27 - i, 20 + i, 32, true);
|
||||
@@ -531,10 +555,9 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
|
||||
let col_clamp = (bit_depth + 6).max(16);
|
||||
let rk = row_kind(tx_type);
|
||||
let ck = col_kind(tx_type);
|
||||
let mut t = [0i32; 64];
|
||||
let rect2 = log2w.abs_diff(log2h) == 1;
|
||||
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
|
||||
if !lossless && bit_depth <= 10 {
|
||||
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
|
||||
if w >= 8 && h >= 8 {
|
||||
transform_lanes::<8>(dequant, residual, &p);
|
||||
} else {
|
||||
@@ -542,6 +565,13 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
|
||||
}
|
||||
return;
|
||||
}
|
||||
scalar_row_pass(dequant, residual, &p, lossless);
|
||||
scalar_col_pass(residual, &p, lossless);
|
||||
}
|
||||
|
||||
fn scalar_row_pass(dequant: &[i32], residual: &mut [i32], p: &Tx2d, lossless: bool) {
|
||||
let (w, h, log2w, row_shift, row_clamp, col_clamp, rk, rect2) = (p.w, p.h, p.log2w, p.row_shift, p.row_clamp, p.col_clamp, p.rk, p.rect2);
|
||||
let mut t = [0i32; 64];
|
||||
let in_w = w.min(32);
|
||||
for i in 0..h {
|
||||
// Every 1D transform maps an all-zero row to zeros (each step is linear with
|
||||
@@ -572,6 +602,11 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
|
||||
residual[i * w + j] = round2(t[j], row_shift).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_col_pass(residual: &mut [i32], p: &Tx2d, lossless: bool) {
|
||||
let (w, h, log2h, col_shift, col_clamp, ck) = (p.w, p.h, p.log2h, p.col_shift, p.col_clamp, p.ck);
|
||||
let mut t = [0i32; 64];
|
||||
for j in 0..w {
|
||||
for i in 0..h {
|
||||
t[i] = residual[i * w + j];
|
||||
@@ -617,11 +652,16 @@ fn transform_1d<T: Lane>(t: &mut [T], kind: Kind1d, n: u32, r: u32) {
|
||||
/// The 2D inverse transform with `L` rows (row pass) or columns (column pass) at a time;
|
||||
/// bit-identical to the one-at-a-time process for bit depths up to 10.
|
||||
fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d) {
|
||||
let mut t = [[0i32; L]; 64];
|
||||
lanes_row_pass(dequant, residual, p, &mut t);
|
||||
lanes_col_pass(residual, p, &mut t);
|
||||
}
|
||||
|
||||
fn lanes_row_pass<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
|
||||
let (w, h) = (p.w, p.h);
|
||||
let in_w = w.min(32);
|
||||
let lo = -(1i32 << (p.col_clamp - 1));
|
||||
let hi = (1i32 << (p.col_clamp - 1)) - 1;
|
||||
let mut t = [[0i32; L]; 64];
|
||||
let mut i0 = 0;
|
||||
while i0 < h {
|
||||
// Rows without coefficients (and rows 32..63) transform to zeros.
|
||||
@@ -649,6 +689,10 @@ fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx
|
||||
}
|
||||
i0 += L;
|
||||
}
|
||||
}
|
||||
|
||||
fn lanes_col_pass<const L: usize>(residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
|
||||
let (w, h) = (p.w, p.h);
|
||||
let mut j0 = 0;
|
||||
while j0 < w {
|
||||
for i in 0..h {
|
||||
|
||||
+11
-1
@@ -34,7 +34,8 @@ impl Rgba {
|
||||
}
|
||||
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
|
||||
pub fn from_hex(s: &str) -> Option<Self> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
@@ -757,6 +758,15 @@ pub fn is_system_name(name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_hex_rejects_multiple_hashes() {
|
||||
assert_eq!(Rgba::from_hex("##FF0000"), None);
|
||||
assert_eq!(Rgba::from_hex("####FF0000"), None);
|
||||
assert_eq!(Rgba::from_hex("#FF0000"), Some(Rgba::rgb(255, 0, 0)));
|
||||
assert_eq!(Rgba::from_hex("FF0000"), Some(Rgba::rgb(255, 0, 0)));
|
||||
assert_eq!(Rgba::from_hex("#FF000080"), Some(Rgba::rgba(255, 0, 0, 128)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_hex_rejects_signs() {
|
||||
assert_eq!(Rgba::from_hex("+12345"), None);
|
||||
|
||||
@@ -355,6 +355,11 @@ pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value {
|
||||
|
||||
/// Targets: `ids` / `id` params, else the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array)
|
||||
&& let Some(v) = a.iter().find(|v| v.as_u64().and_then(|v| u32::try_from(v).ok()).is_none())
|
||||
{
|
||||
return Err(bad("shape", format!("`ids` has an invalid shape id: {v}")));
|
||||
}
|
||||
if let Some(ids) = ids_param(p, "ids")
|
||||
&& !ids.is_empty()
|
||||
{
|
||||
@@ -510,6 +515,20 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
|
||||
}
|
||||
}
|
||||
|
||||
/// Every name on the slide, group members included, and the number of shapes (which exceeds the
|
||||
/// number of names when two shapes share one).
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet<String>, usize) {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
if let Some(v) = current_shapes(doc, sel) {
|
||||
deckcraft_model::walk(v, &mut |sh, _| {
|
||||
names.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
}
|
||||
(names, count)
|
||||
}
|
||||
|
||||
/// The command palette runs a command with `{}`. True when the params doc names a value that
|
||||
/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it.
|
||||
/// A name ending in `?` is optional. Text after `→` is the result, not an input.
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -15,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Animation Styles",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations",
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
@@ -24,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Animation",
|
||||
["Animations", "Advanced Animation"],
|
||||
None,
|
||||
"{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}",
|
||||
"{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}",
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
@@ -44,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}",
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}",
|
||||
has_slide,
|
||||
options
|
||||
),
|
||||
@@ -127,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
|
||||
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
|
||||
}
|
||||
|
||||
fn build(p: &Value) -> Result<Animation> {
|
||||
fn build(p: &Value, slide: (f64, f64)) -> Result<Animation> {
|
||||
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
|
||||
let class = class_of(str_param(p, "class"));
|
||||
let info = match class {
|
||||
@@ -153,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
a.start = start_of(st);
|
||||
}
|
||||
if a.class == AnimClass::Path {
|
||||
a.path = Some(default_path(&a.effect, &a.option));
|
||||
a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option)));
|
||||
} else if p.get("path").is_some() {
|
||||
return Err(bad("animation", "`path` only fits a motion path (class `path`)"));
|
||||
}
|
||||
if a.effect == "spin" {
|
||||
a.amount = Some(360.0);
|
||||
@@ -164,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Most points of a motion path given as an array, and longest path string given as text.
|
||||
const MAX_PATH_POINTS: usize = 1_000;
|
||||
const MAX_PATH_TEXT: usize = 16 * 1024;
|
||||
/// Largest offset of a point, in slide points; anything beyond is not a place on any slide.
|
||||
const MAX_PATH_OFFSET: f64 = 1.0e6;
|
||||
|
||||
/// A number of a path string: slide fractions, rounded to 6 places.
|
||||
fn path_num(v: f64) -> String {
|
||||
let t = format!("{:.6}", v);
|
||||
let t = t.trim_end_matches('0').trim_end_matches('.');
|
||||
if t == "-0" || t.is_empty() { "0".into() } else { t.into() }
|
||||
}
|
||||
|
||||
/// Does a raw motion path string draw: only commands and numbers, every number finite, and at
|
||||
/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.)
|
||||
fn raw_path_draws(text: &str) -> bool {
|
||||
let cs: Vec<char> = text.chars().collect();
|
||||
let (mut i, mut nums) = (0, 0usize);
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') {
|
||||
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
|
||||
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
|
||||
if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default();
|
||||
match tok.parse::<f64>() {
|
||||
Ok(v) if v.is_finite() => nums += 1,
|
||||
_ => return false,
|
||||
}
|
||||
} else if " ,MmLlCcQqHhVvZzE".contains(c) {
|
||||
i += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
nums >= 4
|
||||
}
|
||||
|
||||
/// The motion path `path` asks for, as the OOXML path string animations store: either points
|
||||
/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?`
|
||||
/// to return to the first) or that string itself.
|
||||
fn path_param(p: &Value, slide: (f64, f64)) -> Result<Option<String>> {
|
||||
let Some(v) = p.get("path") else { return Ok(None) };
|
||||
let refuse = |why: &str| bad("animation", format!("`path` {why}"));
|
||||
match v {
|
||||
Value::String(text) => {
|
||||
let ok = text.len() <= MAX_PATH_TEXT
|
||||
&& text.contains(|c: char| c.is_ascii_digit())
|
||||
&& text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c))
|
||||
&& raw_path_draws(text);
|
||||
if !ok {
|
||||
return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)"));
|
||||
}
|
||||
Ok(Some(text.clone()))
|
||||
}
|
||||
Value::Array(points) => {
|
||||
if !(2..=MAX_PATH_POINTS).contains(&points.len()) {
|
||||
return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points")));
|
||||
}
|
||||
if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) {
|
||||
return Err(refuse("can't be placed: the slide has no size"));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (k, pt) in points.iter().enumerate() {
|
||||
let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else {
|
||||
return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points")));
|
||||
};
|
||||
out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1)));
|
||||
}
|
||||
if p.get("closed").and_then(Value::as_bool) == Some(true) {
|
||||
out.push_str("Z ");
|
||||
}
|
||||
out.push('E');
|
||||
Ok(Some(out))
|
||||
}
|
||||
_ => Err(refuse("must be [[x, y], …] or an OOXML path string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default motion paths, in slide-fraction units relative to the shape's position.
|
||||
pub fn default_path(effect: &str, option: &str) -> String {
|
||||
let (dx, dy) = match option {
|
||||
@@ -183,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
s.edit(|doc, sel| {
|
||||
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
|
||||
let sl = Arc::make_mut(sl);
|
||||
@@ -213,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The slide's width and height in points.
|
||||
fn slide_extent(s: &mut Session) -> Result<(f64, f64)> {
|
||||
let size = &s.doc()?.doc.slide_size;
|
||||
Ok((size.width, size.height))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
let path = a.path.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?);
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
@@ -225,12 +327,15 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sl.animations.push(x);
|
||||
}
|
||||
Ok(json!({"count": sl.animations.len()}))
|
||||
Ok(match &path {
|
||||
Some(path) => json!({"count": sl.animations.len(), "path": path}),
|
||||
None => json!({"count": sl.animations.len()}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index = usize_param(p, "index");
|
||||
let index = opt_usize_param(p, "index", "animation.remove")?;
|
||||
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?);
|
||||
@@ -247,7 +352,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").ok_or_else(|| bad("animation.move", "missing `index`"))?;
|
||||
let i = opt_usize_param(p, "index", "animation.move")?.ok_or_else(|| bad("animation.move", "missing `index`"))?;
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?);
|
||||
@@ -263,7 +368,7 @@ fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result<Value> {
|
||||
let index = usize_param(p, "index");
|
||||
let index = opt_usize_param(p, "index", "animation")?;
|
||||
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?);
|
||||
@@ -318,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"byLetter" => TextBuild::ByLetter,
|
||||
_ => TextBuild::AsOne,
|
||||
});
|
||||
let path = path_param(p, slide_extent(s)?)?;
|
||||
each_target(s, p, |a| {
|
||||
if let (Some(path), AnimClass::Path) = (&path, a.class) {
|
||||
a.path = Some(path.clone());
|
||||
}
|
||||
if let Some(o) = &option {
|
||||
// Each target takes the option in its own effect's terms; one it has no such
|
||||
// option for keeps its own.
|
||||
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
|
||||
if let Some(o) = resolve_option(allowed, o) {
|
||||
if a.class == AnimClass::Path {
|
||||
if a.class == AnimClass::Path && path.is_none() {
|
||||
a.path = Some(default_path(&a.effect, &o));
|
||||
}
|
||||
a.option = o;
|
||||
@@ -337,7 +446,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide);
|
||||
let i = match opt_usize_param(p, "index", "animation.clear")? {
|
||||
Some(i) => i,
|
||||
None => s.doc()?.selection.slide,
|
||||
};
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).animations.clear();
|
||||
@@ -432,6 +544,19 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id = r.get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
|
||||
for bad_index in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err());
|
||||
let left = s.execute("animation.get", &json!({})).unwrap();
|
||||
assert_eq!(left.as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_painter_copies_effects_between_shapes() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -567,4 +692,97 @@ mod tests {
|
||||
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_index_is_an_error_not_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id = r.get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![deckcraft_model::ShapeId(id as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
for bad_index in [json!(-1), json!(1.5), json!("x")] {
|
||||
for cmd in ["animation.remove", "animation.timing", "animation.options"] {
|
||||
let r = s.execute(cmd, &json!({"index": bad_index, "duration": 100}));
|
||||
assert!(r.is_err(), "{cmd} accepted index {bad_index}: {r:?}");
|
||||
}
|
||||
let r = s.execute("animation.move", &json!({"index": bad_index, "to": 0}));
|
||||
assert!(r.is_err(), "animation.move accepted index {bad_index}: {r:?}");
|
||||
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(s: &mut Session) -> Option<String> {
|
||||
let all = s.execute("animation.get", &json!({})).unwrap();
|
||||
all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from)
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// #1576: points in slide points, as offsets from the shape, become the slide-fraction path.
|
||||
#[test]
|
||||
fn a_custom_path_takes_its_points() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let w = s.doc().unwrap().doc.slide_size.width;
|
||||
let h = s.doc().unwrap().doc.slide_size.height;
|
||||
let r = s
|
||||
.execute(
|
||||
"animation.add",
|
||||
&json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E");
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E"));
|
||||
// closed shapes end back at the start; set replaces; no path keeps the default
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]}))
|
||||
.unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E"));
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_path_string_is_kept_and_options_edits_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E"));
|
||||
s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_path_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let many: Vec<Value> = (0..2_000).map(|i| json!([i, i])).collect();
|
||||
for bad_path in [
|
||||
json!([[0, 0]]),
|
||||
json!([]),
|
||||
json!([[0, 0], [1]]),
|
||||
json!([[0, 0], ["a", 1]]),
|
||||
json!([[0, 0], [1e300, 1]]),
|
||||
json!([[0, 0], [null, 1]]),
|
||||
json!("M 0 0 L <script> E"),
|
||||
json!("not a path"),
|
||||
json!("eeee1"),
|
||||
json!("M 0 0 L 1e999 0 E"),
|
||||
json!(5),
|
||||
Value::Array(many),
|
||||
Value::String("M 0 0 L 0.1 0.1 ".repeat(10_000)),
|
||||
] {
|
||||
let r = s.execute("animation.add", &json!({"effect": "customPath", "path": bad_path, "ids": [id]}));
|
||||
assert!(r.is_err(), "{bad_path:.60}");
|
||||
}
|
||||
assert!(
|
||||
s.execute("animation.add", &json!({"effect": "fade", "path": [[0, 0], [1, 1]], "ids": [id]})).is_err(),
|
||||
"path only fits a motion path"
|
||||
);
|
||||
assert!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().is_empty(), "a refused add left an animation behind");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ use deckcraft_geom::{Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, edit_geometry, has_doc, has_selection, has_slide, has_two, id_param, ok, str_param, targets,
|
||||
usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -226,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// After an edit: refit every group whose children's geometry changed to the union of its
|
||||
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
|
||||
/// shrinks the group). The children keep their place on the slide: the group's box and its child
|
||||
/// space change together. `old` is the presentation before the edit; only slides the edit
|
||||
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
|
||||
/// that edit.
|
||||
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
|
||||
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
|
||||
// insertion or deletion shifts the indices, and shape ids repeat across slides.
|
||||
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
|
||||
for slide in new.slides.iter_mut() {
|
||||
let Some(before) = old_slides.get(&slide.id) else { continue };
|
||||
if std::sync::Arc::ptr_eq(before, slide) {
|
||||
continue;
|
||||
}
|
||||
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
|
||||
refit_list(Some(&before.shapes), shapes, 0);
|
||||
}
|
||||
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
|
||||
for master in new.masters.iter_mut() {
|
||||
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
|
||||
&& !std::sync::Arc::ptr_eq(before, master)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(master));
|
||||
}
|
||||
}
|
||||
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
|
||||
if let (Some(before), Some(after)) = (before, after)
|
||||
&& !std::sync::Arc::ptr_eq(before, after)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(after));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
|
||||
refit_list(Some(&old.shapes), &mut new.shapes, 0);
|
||||
for layout in new.layouts.iter_mut() {
|
||||
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
|
||||
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
|
||||
// is bounded like the model's other walks.
|
||||
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
|
||||
return;
|
||||
}
|
||||
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
|
||||
for sh in list.iter_mut() {
|
||||
let before = by_id.get(&sh.id).copied();
|
||||
let old_children = before.and_then(|b| match &b.kind {
|
||||
ShapeKind::Group { children, .. } => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
let own_before = before.map(|b| {
|
||||
(
|
||||
b.xfrm,
|
||||
match &b.kind {
|
||||
ShapeKind::Group { child, .. } => Some(*child),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
});
|
||||
let smart = sh.diagram.is_some();
|
||||
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
|
||||
refit_list(old_children, children, depth + 1);
|
||||
if smart {
|
||||
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
|
||||
continue;
|
||||
}
|
||||
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
|
||||
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = sh.xfrm else { continue };
|
||||
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
|
||||
let Some(u) = union_bounds(&xs) else { continue };
|
||||
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
|
||||
let placed = child_to_parent(&group, child, &union);
|
||||
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
|
||||
*child = union;
|
||||
}
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
|
||||
|
||||
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
|
||||
use image::{GrayImage, Rgba, RgbaImage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_selection, str_param, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The effects offered: `a14` element name and label.
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, f64_or, has_selection, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Larger pictures are refused rather than matted slowly (16 megapixels).
|
||||
@@ -184,7 +184,12 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use crate::cmd::base64_encode;
|
||||
|
||||
/// A red disc on a white-to-grey gradient.
|
||||
fn disc() -> image::RgbaImage {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
use crate::Session;
|
||||
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, f64_param, has_doc, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+22
-2127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Background, Fill, PhType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Session, Target};
|
||||
|
||||
#[test]
|
||||
fn applies_the_theme_of_another_deck_and_undoes() {
|
||||
let mut other = Session::with_new();
|
||||
other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap();
|
||||
other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap();
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.masters[0].theme.clone();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let t = &s.doc().unwrap().doc.masters[0].theme;
|
||||
assert_eq!(t.fonts.major.latin, "Lato");
|
||||
assert_eq!(t.fonts.minor.latin, "Source Sans 3");
|
||||
assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456");
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].theme, before);
|
||||
assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err());
|
||||
assert!(s.execute("design.browseTheme", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
|
||||
/// can only find them by kind) and its content layout's title moved.
|
||||
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
|
||||
let mut logo = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
|
||||
let mut t = Session::with_new();
|
||||
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
|
||||
let title = t
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
|
||||
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
name: "Logo".into(),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
m.background = Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
|
||||
for l in &mut m.layouts {
|
||||
l.name = format!("Brand {}", l.name);
|
||||
}
|
||||
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
|
||||
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
|
||||
title.xfrm = Some(x);
|
||||
Ok(x)
|
||||
})
|
||||
.unwrap();
|
||||
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
|
||||
let (data, logo, title) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
|
||||
assert_eq!(r["slides"], json!(2), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
|
||||
let m = &doc.masters[0];
|
||||
assert_ne!(m.id, before.masters[0].id);
|
||||
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
|
||||
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
|
||||
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
|
||||
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
|
||||
// The 4:3 master stretches to this deck's 16:9.
|
||||
let k = doc.slide_size.width / 720.0;
|
||||
let px = pic.xfrm.unwrap();
|
||||
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
|
||||
|
||||
// Each slide moved to the brand layout of its kind, and kept its text.
|
||||
for (old, new) in before.slides.iter().zip(&doc.slides) {
|
||||
let (_, was) = before.layout(old.layout).unwrap();
|
||||
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
|
||||
assert_eq!(owner.id, m.id);
|
||||
assert_eq!(now.kind, was.kind);
|
||||
}
|
||||
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
|
||||
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
|
||||
let (_, lay) = doc.layout(agenda.layout).unwrap();
|
||||
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
|
||||
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
|
||||
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.masters, before.masters);
|
||||
assert_eq!(doc.slides, before.slides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preserved_master_stays_beside_the_template() {
|
||||
let (data, _, _) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
let old = s.doc().unwrap().doc.masters[0].id;
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.masters.len(), 2);
|
||||
assert_eq!(st.doc.masters[1].id, old);
|
||||
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
|
||||
let new = &st.doc.masters[0];
|
||||
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
|
||||
}
|
||||
|
||||
fn undo_len(s: &crate::Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() {
|
||||
let mut s = crate::Session::with_new();
|
||||
let listed = s.execute("design.themes", &json!({})).unwrap();
|
||||
let names: Vec<String> = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]);
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step");
|
||||
let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no effect scheme"), "{err}");
|
||||
assert!(s.execute("design.effects", &json!({})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
s.execute("design.theme", &json!({"name": "Harbor"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.masters[0].theme.clone();
|
||||
s.execute("design.effects", &json!({"name": "soft"})).unwrap();
|
||||
let theme = s.doc().unwrap().doc.masters[0].theme.clone();
|
||||
assert_eq!(theme.name, "Harbor");
|
||||
assert_eq!(theme.colors, before.colors);
|
||||
assert_eq!(theme.fonts, before.fonts);
|
||||
assert_eq!(theme.format.name, "Soft");
|
||||
assert!(theme.format.effects[0].outer_shadow.is_some());
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects");
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.effects", &json!({"name": "Soft"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the same scheme is not an undo step");
|
||||
|
||||
s.execute("design.effects", &json!({"name": "Glow"})).unwrap();
|
||||
let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone();
|
||||
assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6));
|
||||
s.execute("design.effects", &json!({"name": "Plain"})).unwrap();
|
||||
let plain = s.doc().unwrap().doc.masters[0].theme.format.clone();
|
||||
assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none()));
|
||||
assert_eq!(plain.lines[2].width, Some(0.75));
|
||||
}
|
||||
|
||||
fn shapes(s: &Session) -> Vec<deckcraft_model::Shape> {
|
||||
s.doc().unwrap().shapes().to_vec()
|
||||
}
|
||||
|
||||
fn has_title(s: &Session) -> bool {
|
||||
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))
|
||||
}
|
||||
|
||||
fn has_footers(s: &Session) -> bool {
|
||||
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_and_placeholders_edit_the_open_master_or_layout() {
|
||||
for id in ["master.preserve", "master.placeholders"] {
|
||||
assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}");
|
||||
}
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
|
||||
|
||||
s.execute("view.slideMaster", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 });
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 });
|
||||
assert!(!s.doc().unwrap().doc.masters[0].preserve);
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step");
|
||||
s.execute("master.preserve", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].preserve);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.masters[0].preserve);
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(&s));
|
||||
assert!(!has_footers(&s));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(has_footers(&s));
|
||||
|
||||
master_view_closed_rejects(&mut s, blank);
|
||||
}
|
||||
|
||||
fn master_view_closed_rejects(s: &mut Session, blank: usize) {
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let err = s.execute("master.preserve", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("open Slide Master view"), "{err}");
|
||||
let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err();
|
||||
assert!(err.to_string().contains("open Slide Master view"), "{err}");
|
||||
let err = s.execute("master.placeholders", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("give title or footers"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
|
||||
layout_placeholders_edit_one_undo_step(s, blank);
|
||||
}
|
||||
|
||||
fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) {
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert!(!has_title(s));
|
||||
assert!(has_footers(s));
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state");
|
||||
|
||||
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(s));
|
||||
assert!(!has_footers(s));
|
||||
let title = shapes(s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert_eq!(title.name, "Title 1");
|
||||
assert_eq!(title.ph.unwrap().idx, 0);
|
||||
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!has_title(s));
|
||||
assert!(has_footers(s));
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
|
||||
let restored = shapes(s);
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
|
||||
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
|
||||
let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0];
|
||||
assert_eq!(run.text, "‹#›");
|
||||
assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum"));
|
||||
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
|
||||
missing_title_placeholder_is_recreated(s);
|
||||
}
|
||||
|
||||
fn missing_title_placeholder_is_recreated(s: &mut Session) {
|
||||
s.edit(|doc, _| {
|
||||
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("master.placeholders", &json!({"title": true})).unwrap();
|
||||
let title = shapes(s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
|
||||
assert_eq!(title.name, "Title");
|
||||
let box_ = title.xfrm.unwrap();
|
||||
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use super::{
|
||||
Arc, Result, Session, Value, bad, builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, json, master_index, ok, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name_v = p.get("name");
|
||||
let colors_v = p.get("colors");
|
||||
if name_v.is_none() && colors_v.is_none() {
|
||||
let sc = current_colors(s)?;
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) });
|
||||
return ok();
|
||||
}
|
||||
let given = match name_v {
|
||||
None => None,
|
||||
Some(Value::String(n)) => {
|
||||
let n = n.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad("design.colors", "name is empty"));
|
||||
}
|
||||
Some(n.to_string())
|
||||
}
|
||||
Some(_) => return Err(bad("design.colors", "`name` must be a string")),
|
||||
};
|
||||
let slots = parse_color_slots(colors_v)?;
|
||||
let current = current_colors(s)?;
|
||||
let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?;
|
||||
if next == current {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?);
|
||||
m.theme.colors = next;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize;
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?);
|
||||
let v = deckcraft_model::theme::theme_variants(&m.theme);
|
||||
m.theme.colors =
|
||||
v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"variant": index}))
|
||||
}
|
||||
|
||||
pub(super) fn variants(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default();
|
||||
let v = deckcraft_model::theme::theme_variants(&th);
|
||||
let current = v.iter().position(|c| c.colors == th.colors.colors);
|
||||
Ok(json!({
|
||||
"theme": th.name,
|
||||
"current": current,
|
||||
"variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_colors(s: &Session) -> Result<deckcraft_color::ColorScheme> {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master"))
|
||||
}
|
||||
|
||||
fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value {
|
||||
let mut colors = serde_json::Map::new();
|
||||
for slot in deckcraft_color::SchemeSlot::THEME {
|
||||
colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex())));
|
||||
}
|
||||
json!({"name": sc.name, "colors": colors})
|
||||
}
|
||||
|
||||
/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such
|
||||
/// as `bg1` are rejected because `ColorScheme::set` ignores them.
|
||||
fn parse_color_slots(colors_v: Option<&Value>) -> Result<Option<Vec<(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)>>> {
|
||||
let Some(v) = colors_v else { return Ok(None) };
|
||||
let Some(map) = v.as_object() else {
|
||||
return Err(bad("design.colors", "`colors` must be an object"));
|
||||
};
|
||||
if map.is_empty() {
|
||||
return Err(bad("design.colors", "`colors` is empty"));
|
||||
}
|
||||
let mut slots = Vec::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else {
|
||||
return Err(bad("design.colors", format!("unknown colour `{k}`")));
|
||||
};
|
||||
if !deckcraft_color::SchemeSlot::THEME.contains(&slot) {
|
||||
return Err(bad("design.colors", format!("`{k}` is not a theme colour")));
|
||||
}
|
||||
let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else {
|
||||
return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB")));
|
||||
};
|
||||
slots.push((slot, rgb));
|
||||
}
|
||||
Ok(Some(slots))
|
||||
}
|
||||
|
||||
/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name
|
||||
/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error.
|
||||
fn next_scheme(
|
||||
current: &deckcraft_color::ColorScheme,
|
||||
given: Option<&str>,
|
||||
slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>,
|
||||
) -> Result<deckcraft_color::ColorScheme> {
|
||||
let mut next = current.clone();
|
||||
if let Some(name) = given {
|
||||
if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) {
|
||||
next = builtin;
|
||||
} else if slots.is_none() {
|
||||
return Err(bad("design.colors", format!("no colour scheme `{name}`")));
|
||||
}
|
||||
next.name = name.to_string();
|
||||
}
|
||||
if let Some(changes) = slots {
|
||||
for (slot, rgb) in changes {
|
||||
next.set(*slot, *rgb);
|
||||
}
|
||||
if given.is_none() {
|
||||
next.name = "Custom".into();
|
||||
}
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub(super) fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name_v = p.get("name");
|
||||
let major_v = p.get("major");
|
||||
let minor_v = p.get("minor");
|
||||
if name_v.is_none() && major_v.is_none() && minor_v.is_none() {
|
||||
let scheme = current_fonts(s)?;
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) });
|
||||
return ok();
|
||||
}
|
||||
let given = match name_v {
|
||||
None => None,
|
||||
Some(Value::String(n)) => {
|
||||
let n = n.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad("design.fonts", "name is empty"));
|
||||
}
|
||||
Some(n.to_string())
|
||||
}
|
||||
Some(_) => return Err(bad("design.fonts", "`name` must be a string")),
|
||||
};
|
||||
let major = face_param("major", major_v)?;
|
||||
let minor = face_param("minor", minor_v)?;
|
||||
let current = current_fonts(s)?;
|
||||
let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?;
|
||||
if next == current {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn current_fonts(s: &Session) -> Result<deckcraft_model::theme::FontScheme> {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master"))
|
||||
}
|
||||
|
||||
fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value {
|
||||
json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin})
|
||||
}
|
||||
|
||||
/// `None` when the key is absent. A present face must be a non-blank string.
|
||||
fn face_param(key: &str, v: Option<&Value>) -> Result<Option<String>> {
|
||||
match v {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) => {
|
||||
let s = s.trim();
|
||||
if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) }
|
||||
}
|
||||
Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East
|
||||
/// Asian, complex-script and per-script slots stay). The stored name is the given string when
|
||||
/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name.
|
||||
/// Faces without a name are a custom scheme: an omitted `minor` copies `major`.
|
||||
fn next_font_scheme(
|
||||
current: &deckcraft_model::theme::FontScheme,
|
||||
given: Option<&str>,
|
||||
major: Option<&str>,
|
||||
minor: Option<&str>,
|
||||
) -> Result<deckcraft_model::theme::FontScheme> {
|
||||
let mut next = current.clone();
|
||||
if let Some(name) = given {
|
||||
let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) {
|
||||
next = builtin;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !replaced && major.is_none() && minor.is_none() {
|
||||
return Err(bad("design.fonts", format!("no font scheme `{name}`")));
|
||||
}
|
||||
if major.is_some() || minor.is_some() || !replaced {
|
||||
next.name = name.to_string();
|
||||
}
|
||||
}
|
||||
if given.is_none() {
|
||||
if let Some(face) = major {
|
||||
next.major.latin = face.to_string();
|
||||
if minor.is_none() {
|
||||
next.minor.latin = face.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(face) = minor {
|
||||
next.minor.latin = face.to_string();
|
||||
}
|
||||
next.name = "Custom".into();
|
||||
} else {
|
||||
if let Some(face) = major {
|
||||
next.major.latin = face.to_string();
|
||||
}
|
||||
if let Some(face) = minor {
|
||||
next.minor.latin = face.to_string();
|
||||
}
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?;
|
||||
let scheme = builtin_format_schemes()
|
||||
.into_iter()
|
||||
.find(|f| f.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?;
|
||||
let stored = scheme.name.clone();
|
||||
let same = {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme)
|
||||
};
|
||||
if same {
|
||||
return Ok(json!({"name": stored}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": stored}))
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
use super::{
|
||||
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
|
||||
// that layout. With neither key, follow the current slide's layout.
|
||||
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
|
||||
None
|
||||
} else {
|
||||
usize_param(p, "layout")
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
|
||||
};
|
||||
let target = match layout {
|
||||
Some(l) => Target::Layout { master: mi, layout: l },
|
||||
None => Target::Master { master: mi },
|
||||
};
|
||||
s.select(|_, sel| {
|
||||
sel.target = target;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(serde_json::to_value(target).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) }
|
||||
}
|
||||
|
||||
/// The four handout-master placeholders PowerPoint offers, in a page of `size`.
|
||||
pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
|
||||
let (w, h) = (size.width, size.height);
|
||||
let (name, idx, x, y) = match kind {
|
||||
PhType::Header => ("Header Placeholder", 0, 0.0, 0.0),
|
||||
PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0),
|
||||
PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95),
|
||||
_ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95),
|
||||
};
|
||||
let mut text = TextBody::default();
|
||||
if let Some(p) = text.paragraphs.first_mut()
|
||||
&& matches!(kind, PhType::Date | PhType::SlideNum)
|
||||
{
|
||||
p.props.align = Some(deckcraft_model::text::Align::Right);
|
||||
}
|
||||
Shape {
|
||||
id,
|
||||
name: format!("{name} {}", idx + 1),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const HANDOUT_KINDS: [(&str, PhType); 4] =
|
||||
[("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)];
|
||||
|
||||
pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let created = s.doc()?.doc.handout_master.is_none();
|
||||
if created {
|
||||
s.edit(|doc, _| {
|
||||
let mut m = deckcraft_model::Master {
|
||||
name: "Handout Master".into(),
|
||||
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
|
||||
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
let size = doc.notes_size;
|
||||
for (_, k) in HANDOUT_KINDS {
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?);
|
||||
m.shapes.push(handout_placeholder(k, size, id));
|
||||
}
|
||||
doc.handout_master = Some(Arc::new(m));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::Handout;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(json!({"created": created}))
|
||||
}
|
||||
|
||||
pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, _| {
|
||||
let size = doc.notes_size;
|
||||
let mut ids = vec![];
|
||||
for (key, _) in HANDOUT_KINDS {
|
||||
let id = match p.get(key).and_then(Value::as_bool) {
|
||||
Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)),
|
||||
None => None,
|
||||
};
|
||||
ids.push(id);
|
||||
}
|
||||
let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?);
|
||||
for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) {
|
||||
let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue };
|
||||
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
|
||||
if on && !has {
|
||||
m.shapes.push(handout_placeholder(kind, size, id));
|
||||
} else if !on {
|
||||
m.shapes.retain(|x| x.ph_type() != Some(kind));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let m = s.doc()?.doc.handout_master.clone();
|
||||
let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k)));
|
||||
Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)}))
|
||||
}
|
||||
|
||||
pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) }
|
||||
}
|
||||
|
||||
const NOTES_KINDS: [(&str, PhType); 6] = [
|
||||
("header", PhType::Header),
|
||||
("date", PhType::Date),
|
||||
("slideImage", PhType::SlideImage),
|
||||
("body", PhType::Body),
|
||||
("footer", PhType::Footer),
|
||||
("pageNumber", PhType::SlideNum),
|
||||
];
|
||||
|
||||
/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master.
|
||||
pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
|
||||
let (w, h) = (size.width, size.height);
|
||||
match kind {
|
||||
PhType::SlideImage => Shape {
|
||||
id,
|
||||
name: "Slide Image Placeholder 3".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)),
|
||||
..Default::default()
|
||||
},
|
||||
PhType::Body => {
|
||||
let mut text = TextBody::default();
|
||||
text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")];
|
||||
Shape {
|
||||
id,
|
||||
name: "Notes Placeholder 4".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut s = handout_placeholder(kind, size, id);
|
||||
if let Some(ph) = s.ph.as_mut() {
|
||||
ph.idx = match kind {
|
||||
PhType::Header => 0,
|
||||
PhType::Date => 1,
|
||||
PhType::Footer => 4,
|
||||
_ => 5,
|
||||
};
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty());
|
||||
if created {
|
||||
s.edit(|doc, _| {
|
||||
let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master {
|
||||
name: "Notes Master".into(),
|
||||
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
|
||||
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
});
|
||||
let size = doc.notes_size;
|
||||
for (_, k) in NOTES_KINDS {
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?);
|
||||
m.shapes.push(notes_placeholder(k, size, id));
|
||||
}
|
||||
doc.notes_master = Some(Arc::new(m));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::NotesMaster;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(json!({"created": created}))
|
||||
}
|
||||
|
||||
pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, _| {
|
||||
let size = doc.notes_size;
|
||||
let ids: Vec<ShapeId> =
|
||||
NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::<Result<_>>()?;
|
||||
let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?);
|
||||
for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) {
|
||||
let Some(on) = p.get(key).and_then(Value::as_bool) else { continue };
|
||||
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
|
||||
if on && !has {
|
||||
m.shapes.push(notes_placeholder(kind, size, id));
|
||||
} else if !on {
|
||||
m.shapes.retain(|x| x.ph_type() != Some(kind));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let m = s.doc()?.doc.notes_master.clone();
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, kind) in NOTES_KINDS {
|
||||
out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind)))));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::Slides;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Custom Layout").to_string();
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
|
||||
let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
|
||||
let size = doc.slide_size;
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?);
|
||||
let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| {
|
||||
t.id = tid;
|
||||
t.text = Some(TextBody::default());
|
||||
t
|
||||
});
|
||||
let _ = size;
|
||||
let at = match sel.target {
|
||||
Target::Layout { layout, .. } => layout + 1,
|
||||
_ => m.layouts.len(),
|
||||
};
|
||||
m.layouts.insert(
|
||||
at.min(m.layouts.len()),
|
||||
deckcraft_model::Layout {
|
||||
id,
|
||||
name,
|
||||
kind: deckcraft_model::LayoutType::Custom,
|
||||
shapes: title.into_iter().collect(),
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) };
|
||||
Ok(json!({"id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let st = s.doc()?;
|
||||
let (m, l) = match st.selection.target {
|
||||
Target::Layout { master, layout } => (master, layout),
|
||||
_ => (master_index(&st.doc, &st.selection), 0),
|
||||
};
|
||||
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
|
||||
}
|
||||
|
||||
pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mi, li) = layout_target(s, p, "master.renameLayout")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string();
|
||||
s.edit(|doc, _| {
|
||||
let l =
|
||||
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?;
|
||||
l.name = name;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mi, li) = layout_target(s, p, "master.deleteLayout")?;
|
||||
s.edit(|doc, sel| {
|
||||
let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?;
|
||||
if doc.slides.iter().any(|x| x.layout == id) {
|
||||
return Err(bad("master.deleteLayout", "slides use this layout"));
|
||||
}
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?);
|
||||
if m.layouts.len() <= 1 {
|
||||
return Err(bad("master.deleteLayout", "a master needs at least one layout"));
|
||||
}
|
||||
m.layouts.remove(li);
|
||||
sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) };
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = match str_param(p, "kind").unwrap_or("content") {
|
||||
"text" => PhType::Body,
|
||||
"picture" => PhType::Picture,
|
||||
"chart" => PhType::Chart,
|
||||
"table" => PhType::Table,
|
||||
"media" => PhType::Media,
|
||||
_ => PhType::Obj,
|
||||
};
|
||||
if !matches!(s.doc()?.selection.target, Target::Layout { .. }) {
|
||||
return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view"));
|
||||
}
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4));
|
||||
let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1;
|
||||
let shape = Shape {
|
||||
xfrm: Some(rect),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
|
||||
text: Some(TextBody::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
pub(super) fn preserve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (on_master, mi, li, current) = match st.selection.target {
|
||||
Target::Master { master } => {
|
||||
let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?;
|
||||
(true, master, 0, on)
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
let on = st
|
||||
.doc
|
||||
.masters
|
||||
.get(master)
|
||||
.and_then(|m| m.layouts.get(layout))
|
||||
.map(|l| l.preserve)
|
||||
.ok_or_else(|| bad("master.preserve", "no such layout"))?;
|
||||
(false, master, layout, on)
|
||||
}
|
||||
Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")),
|
||||
};
|
||||
let next = bool_param(p, "preserve").unwrap_or(!current);
|
||||
if next == current {
|
||||
return Ok(json!({"preserve": next}));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
if on_master {
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?);
|
||||
m.preserve = next;
|
||||
} else {
|
||||
let l =
|
||||
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?;
|
||||
l.preserve = next;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"preserve": next}))
|
||||
}
|
||||
|
||||
pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let title = bool_param(p, "title");
|
||||
let footers = bool_param(p, "footers");
|
||||
if title.is_none() && footers.is_none() {
|
||||
return Err(bad("master.placeholders", "give title or footers"));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
if matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("master.placeholders", "open Slide Master view"));
|
||||
}
|
||||
let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
|
||||
let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
let title_changes = title.is_some_and(|want| want != has_title);
|
||||
let footer_changes = footers.is_some_and(|want| want != has_footers);
|
||||
if !title_changes && !footer_changes {
|
||||
return Ok(json!({"title": has_title, "footers": has_footers}));
|
||||
}
|
||||
let mut add = Vec::new();
|
||||
if title == Some(true) && !has_title {
|
||||
add.push(PhType::Title);
|
||||
}
|
||||
if footers == Some(true) && !has_footers {
|
||||
add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]);
|
||||
}
|
||||
let size = st.doc.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
let mut ids = Vec::with_capacity(add.len());
|
||||
for _ in 0..add.len() {
|
||||
ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?));
|
||||
}
|
||||
let prototypes = match sel.target {
|
||||
Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(),
|
||||
Target::Slides => Vec::new(),
|
||||
Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")),
|
||||
};
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?;
|
||||
if title == Some(false) {
|
||||
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
}
|
||||
if footers == Some(false) {
|
||||
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
}
|
||||
for (kind, id) in add.into_iter().zip(ids) {
|
||||
let idx = free_ph_idx(shapes, preferred_ph_idx(kind));
|
||||
let proto = prototypes.iter().find(|sh| match kind {
|
||||
PhType::Title => sh.ph_type().is_some_and(PhType::is_title),
|
||||
other => sh.ph_type() == Some(other),
|
||||
});
|
||||
let shape = match proto {
|
||||
Some(src) => retarget_placeholder(src.clone(), id, idx),
|
||||
None => synthesized_placeholder(kind, id, idx, size),
|
||||
};
|
||||
shapes.push(shape);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc()?;
|
||||
let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
|
||||
let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
Ok(json!({"title": title, "footers": footers}))
|
||||
}
|
||||
|
||||
fn preferred_ph_idx(kind: PhType) -> u32 {
|
||||
match kind {
|
||||
PhType::Title | PhType::CtrTitle => 0,
|
||||
PhType::Date => 10,
|
||||
PhType::Footer => 11,
|
||||
PhType::SlideNum => 12,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 {
|
||||
let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx));
|
||||
if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) }
|
||||
}
|
||||
|
||||
/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide
|
||||
/// number keeps its `‹#›` field.
|
||||
fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape {
|
||||
sh.id = id;
|
||||
if let Some(ph) = sh.ph.as_mut() {
|
||||
ph.idx = idx;
|
||||
}
|
||||
let slide_num = sh.ph_type() == Some(PhType::SlideNum);
|
||||
if slide_num {
|
||||
let paras = vec![slide_number_paragraph()];
|
||||
if let Some(text) = sh.text.as_mut() {
|
||||
text.paragraphs = paras;
|
||||
} else {
|
||||
sh.text = Some(TextBody { paragraphs: paras, ..Default::default() });
|
||||
}
|
||||
} else if let Some(text) = sh.text.as_mut() {
|
||||
text.paragraphs.clear();
|
||||
}
|
||||
sh
|
||||
}
|
||||
|
||||
fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape {
|
||||
let (name, text) = match kind {
|
||||
PhType::Date => ("Date Placeholder", TextBody::default()),
|
||||
PhType::Footer => ("Footer Placeholder", TextBody::default()),
|
||||
PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }),
|
||||
_ => ("Title", TextBody::default()),
|
||||
};
|
||||
Shape {
|
||||
id,
|
||||
name: name.into(),
|
||||
xfrm: Some(placeholder_rect(kind, size)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard placeholder boxes at 960×540, scaled to the slide size.
|
||||
fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm {
|
||||
let sx = size.width / 960.0;
|
||||
let sy = size.height / 540.0;
|
||||
let (x, y, w, h) = match kind {
|
||||
PhType::Date => (60.0, 500.0, 216.0, 28.0),
|
||||
PhType::Footer => (318.0, 500.0, 324.0, 28.0),
|
||||
PhType::SlideNum => (684.0, 500.0, 216.0, 28.0),
|
||||
_ => (60.0, 29.0, 840.0, 104.0),
|
||||
};
|
||||
deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy)
|
||||
}
|
||||
|
||||
fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
deckcraft_model::Paragraph {
|
||||
runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||
f64_param, ok, opt_usize_param, usize_param, with_param,
|
||||
};
|
||||
|
||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
// Check the index before anything is edited: a picture is added to the media first.
|
||||
let index = opt_usize_param(p, "index", "design.background")?;
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let color = color_param(p, "color");
|
||||
let style = usize_param(p, "style");
|
||||
let op = if reset {
|
||||
BgOp::Set(None)
|
||||
} else if let Some(mut c) = color {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(&mut c, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
|
||||
} else if let Some(i) = style {
|
||||
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
|
||||
let slots =
|
||||
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
|
||||
let i = i.clamp(1, 12) - 1;
|
||||
BgOp::Set(Some(Background::Ref {
|
||||
idx: 1001 + (i / 4) as u32,
|
||||
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
|
||||
}))
|
||||
} else if let Some(g) = p.get("gradient") {
|
||||
let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
|
||||
g.rotate_with_shape = false;
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
|
||||
} else if let Some(pt) = p.get("pattern") {
|
||||
BgOp::Set(Some(Background::Fill {
|
||||
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
|
||||
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
|
||||
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
|
||||
}),
|
||||
}))
|
||||
} else if p.get("picture").is_some() {
|
||||
let (name, bytes) =
|
||||
crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
|
||||
let ct = crate::cmd::insert::content_type(&name, &bytes);
|
||||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?;
|
||||
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
if let Some(pp) = &props {
|
||||
apply_picture_props(&mut pf, pp);
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(&mut pf, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
|
||||
} else if props.is_some() || transparency.is_some() {
|
||||
BgOp::Adjust
|
||||
} else {
|
||||
return Err(bad(
|
||||
"design.background",
|
||||
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
let current = background_of(&st.doc, &st.selection, index);
|
||||
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
|
||||
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
|
||||
if props.is_some() && !picture {
|
||||
return Err(bad("design.background", "picture options need a picture background"));
|
||||
}
|
||||
if transparency.is_some() && !picture && !solid {
|
||||
return Err(bad("design.background", "transparency needs a solid or picture background"));
|
||||
}
|
||||
let mut next = current.clone();
|
||||
if let Some(bg) = next.as_mut() {
|
||||
adjust_background(bg, props.as_ref(), transparency);
|
||||
}
|
||||
if next == current { None } else { Some(next) }
|
||||
};
|
||||
let Some(next) = next else { return ok() };
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, index, false, next);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let BgOp::Set(bg) = op else { return ok() };
|
||||
let unchanged = {
|
||||
let st = s.doc()?;
|
||||
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
|
||||
};
|
||||
if unchanged {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, index, all, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
enum BgOp {
|
||||
Set(Option<Background>),
|
||||
Adjust,
|
||||
}
|
||||
|
||||
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
|
||||
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
|
||||
let fallback = (!doc.masters.is_empty()).then_some(0);
|
||||
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
|
||||
}
|
||||
|
||||
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
|
||||
if all && bg.is_some() {
|
||||
let masters = used_master_indices(doc);
|
||||
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
|
||||
let layouts_match = !masters.is_empty()
|
||||
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
|
||||
slides_match && masters_match && layouts_match
|
||||
} else {
|
||||
slides_match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
|
||||
match sel.target {
|
||||
Target::Master { master } => {
|
||||
if let Some(m) = doc.masters.get_mut(master) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
|
||||
l.background = bg;
|
||||
}
|
||||
}
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
if let Some(m) = crate::page_master_mut(doc, t) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
if all && bg.is_some() {
|
||||
for idx in used_master_indices(doc) {
|
||||
if let Some(m) = doc.masters.get_mut(idx) {
|
||||
let m = Arc::make_mut(m);
|
||||
m.background = bg.clone();
|
||||
for l in &mut m.layouts {
|
||||
l.background = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).background = bg.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_slide_background(s: &mut Session) -> Result<Value> {
|
||||
let bg = {
|
||||
let st = s.doc()?;
|
||||
if !matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("design.background", "copy applies to slides"));
|
||||
}
|
||||
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
|
||||
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
|
||||
let masters_match = if bg.is_some() {
|
||||
let masters = used_master_indices(&st.doc);
|
||||
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if slides_match && masters_match {
|
||||
return ok();
|
||||
}
|
||||
bg
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, None, true, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
|
||||
match bg {
|
||||
Background::Fill { fill: Fill::Picture(pf) } => {
|
||||
if let Some(pp) = props {
|
||||
apply_picture_props(pf, pp);
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(pf, t);
|
||||
}
|
||||
}
|
||||
Background::Fill { fill: Fill::Solid { color } } => {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(color, t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
|
||||
let a = 1.0 - t.clamp(0.0, 1.0);
|
||||
pf.alpha = (a < 1.0).then_some(a);
|
||||
}
|
||||
|
||||
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
|
||||
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t > 0.0 {
|
||||
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
|
||||
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||||
pf.upright = !up;
|
||||
}
|
||||
match pp.get("tile").and_then(Value::as_bool) {
|
||||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
}
|
||||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||||
_ => {}
|
||||
}
|
||||
match &mut pf.mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
if let Some(v) = f("offsetX") {
|
||||
*tx = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("offsetY") {
|
||||
*ty = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("scaleX") {
|
||||
*sx = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(v) = f("scaleY") {
|
||||
*sy = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||||
*flip = m.to_string();
|
||||
}
|
||||
if let Some(a) =
|
||||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||||
{
|
||||
*align = a.to_string();
|
||||
}
|
||||
}
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||||
*slot = v.clamp(-10.0, 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, sel| {
|
||||
if let Some(sl) = doc.slides.get_mut(sel.slide) {
|
||||
let sl = Arc::make_mut(sl);
|
||||
sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
use super::{
|
||||
Arc, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, TextBody, Value, bad, bool_or, bool_param, defaults, f64_param,
|
||||
json, ok, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) {
|
||||
for sh in shapes {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = x.x * sx + ox;
|
||||
x.y = x.y * sy + oy;
|
||||
x.w *= sx;
|
||||
x.h *= sy;
|
||||
}
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
deckcraft_model::edit::format_all(t, &|r| {
|
||||
if let Some(sz) = r.size.as_mut() {
|
||||
*sz = (*sz * k).max(1.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let size = if let Some(pre) = str_param(p, "preset") {
|
||||
let k = pre.to_ascii_lowercase();
|
||||
let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k));
|
||||
let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?;
|
||||
Size::new(*w, *h)
|
||||
} else {
|
||||
Size::new(
|
||||
f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0),
|
||||
f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0),
|
||||
)
|
||||
};
|
||||
let size = match str_param(p, "orientation") {
|
||||
Some("portrait") if size.width > size.height => Size::new(size.height, size.width),
|
||||
Some("landscape") if size.height > size.width => Size::new(size.height, size.width),
|
||||
Some("portrait" | "landscape") | None => size,
|
||||
Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))),
|
||||
};
|
||||
let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string();
|
||||
s.edit(|doc, _| {
|
||||
let old = doc.slide_size;
|
||||
let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
|
||||
let (sx, sy, k, ox, oy) = match mode.as_str() {
|
||||
"none" => (1.0, 1.0, 1.0, 0.0, 0.0),
|
||||
"maximize" => {
|
||||
let k = sx.max(sy);
|
||||
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
|
||||
}
|
||||
_ => {
|
||||
let k = sx.min(sy);
|
||||
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
|
||||
}
|
||||
};
|
||||
// Masters and layouts stretch to the new size; slides scale by mode.
|
||||
let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
|
||||
for m in &mut doc.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0);
|
||||
for l in &mut m.layouts {
|
||||
scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
for sl in &mut doc.slides {
|
||||
scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy);
|
||||
}
|
||||
doc.slide_size = size;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"w": size.width, "h": size.height}))
|
||||
}
|
||||
|
||||
pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, sel| {
|
||||
let hf = &mut doc.header_footer;
|
||||
if let Some(v) = bool_param(p, "date") {
|
||||
hf.date = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateText") {
|
||||
hf.date_text = v.into();
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateFormat") {
|
||||
// Refused rather than ignored: a format that renders nothing would leave the
|
||||
// placeholder showing its saved text for ever.
|
||||
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
|
||||
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
|
||||
}
|
||||
hf.date_format = v.into();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "slideNumber") {
|
||||
hf.slide_number = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "footer") {
|
||||
hf.footer = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "footerText") {
|
||||
hf.footer_text = v.into();
|
||||
hf.footer = hf.footer || !v.is_empty();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "hideOnTitle") {
|
||||
hf.hide_on_title = v;
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let format_given = str_param(p, "dateFormat").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue };
|
||||
let lay = doc.layout(layout).map(|(_, l)| l.clone());
|
||||
let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title);
|
||||
let want = |k: PhType| {
|
||||
!(hf.hide_on_title && is_title)
|
||||
&& match k {
|
||||
PhType::Date => hf.date,
|
||||
PhType::SlideNum => hf.slide_number,
|
||||
PhType::Footer => hf.footer,
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let mut new_ids = vec![];
|
||||
for _ in 0..3 {
|
||||
new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?));
|
||||
}
|
||||
let Some(sl) = doc.slides.get_mut(i) else { continue };
|
||||
let sl = Arc::make_mut(sl);
|
||||
for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() {
|
||||
let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k));
|
||||
match (has, want(k)) {
|
||||
(Some(pos), false) => {
|
||||
sl.shapes.remove(pos);
|
||||
}
|
||||
(None, true) => {
|
||||
let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned());
|
||||
let Some(lp) = lp else { continue };
|
||||
let body = match k {
|
||||
PhType::SlideNum => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run {
|
||||
text: "‹#›".into(),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: "slidenum".into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
PhType::Date if hf.date_text.is_empty() => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run {
|
||||
text: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
PhType::Date => TextBody::from_text(&hf.date_text),
|
||||
_ => TextBody::from_text(&hf.footer_text),
|
||||
};
|
||||
sl.shapes.push(Shape {
|
||||
id: new_ids.get(n).copied().unwrap_or_default(),
|
||||
name: lp.name.clone(),
|
||||
ph: lp.ph.clone(),
|
||||
text: Some(body),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Footer => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The date field type `format` names, `datetime1` when it is empty or unknown.
|
||||
fn date_kind(format: &str) -> &str {
|
||||
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
|
||||
}
|
||||
|
||||
/// The saved text of a date field of `format`, as it shows now.
|
||||
fn date_now(format: &str) -> String {
|
||||
let kind = date_kind(format);
|
||||
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
|
||||
}
|
||||
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let field_now = runs().find_map(|r| match &r.kind {
|
||||
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let has_field = field_now.is_some();
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
// Automatic: keep the field, but follow a format that has changed.
|
||||
"" if field_now.as_deref() == Some(want) => return,
|
||||
"" if has_field && format.is_empty() => return,
|
||||
"" => (date_now(format), RunKind::Field { field: want.into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
|
||||
}
|
||||
|
||||
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
|
||||
/// field. Empty where there is no clock (wasm).
|
||||
pub fn today() -> String {
|
||||
date_field_text("datetime1")
|
||||
}
|
||||
|
||||
/// What date field `kind` shows now (empty if unknown or there is no clock).
|
||||
pub fn date_field_text(kind: &str) -> String {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
|
||||
pub fn civil(z: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
|
||||
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
|
||||
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
|
||||
// 0..146_097 days from it always fits.
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_footer_date_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The date field of the Date placeholders of every slide: (saved text, field type).
|
||||
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.map(|r| {
|
||||
let kind = match &r.kind {
|
||||
RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
};
|
||||
(r.text.clone(), kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> String {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
|
||||
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
|
||||
}
|
||||
|
||||
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
|
||||
/// The clock is read before and after the command, so a second ticking over between the
|
||||
/// command and the read cannot fail the comparison. Returns the run text.
|
||||
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
|
||||
let before = sample(kind);
|
||||
s.execute("design.headerFooter", ¶ms).unwrap();
|
||||
let after = sample(kind);
|
||||
let got = dates(s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some(kind));
|
||||
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
|
||||
got[0].0.clone()
|
||||
}
|
||||
|
||||
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
|
||||
/// existed for this and was never read: the placeholder was always `datetime1`, and a
|
||||
/// `dateFormat` parameter was accepted and silently dropped.
|
||||
#[test]
|
||||
fn the_automatic_date_takes_the_format_it_is_given() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
// Default: the short date, as before.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
|
||||
|
||||
// A format: the field type changes and the saved text is that format's.
|
||||
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
|
||||
|
||||
// A time format works the same way, and these two differ, so the format is really read.
|
||||
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_ne!(time, sample("datetime5"));
|
||||
|
||||
// Asking again changes nothing (and does not rewrite the run).
|
||||
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_eq!(again, time);
|
||||
|
||||
// A fixed date still wins: plain text, no field.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
|
||||
|
||||
// Back to automatic: the remembered format comes back, not datetime1.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
|
||||
// An unknown format is refused rather than ignored, and changes nothing.
|
||||
for bad in ["datetime0", "datetime14", "nonsense", ""] {
|
||||
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
|
||||
let got = dates(&s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
|
||||
use deckcraft_model::{Background, Fill};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, UiRequest};
|
||||
|
||||
fn scheme(s: &Session) -> deckcraft_color::ColorScheme {
|
||||
s.doc().unwrap().doc.masters[0].theme.colors.clone()
|
||||
}
|
||||
|
||||
fn font_scheme(s: &Session) -> FontScheme {
|
||||
s.doc().unwrap().doc.masters[0].theme.fonts.clone()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn png() -> String {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
crate::cmd::base64_encode(&bytes)
|
||||
}
|
||||
|
||||
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
|
||||
other => panic!("expected a picture background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commands_reject_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("master.insertLayout", &json!({})).unwrap();
|
||||
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
|
||||
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
|
||||
let n = count(&s);
|
||||
let before = names(&s);
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
|
||||
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
|
||||
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
|
||||
assert_eq!(count(&s), n);
|
||||
assert_eq!(names(&s), before);
|
||||
}
|
||||
s.execute("master.deleteLayout", &json!({})).unwrap();
|
||||
assert_eq!(count(&s), n - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||||
assert_eq!(super::civil(20_734), (2026, 10, 8));
|
||||
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
|
||||
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
|
||||
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
|
||||
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
|
||||
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_dialog_apply_and_reject() {
|
||||
let mut s = Session::with_new();
|
||||
let original = scheme(&s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.colors", &json!({})).unwrap();
|
||||
s.execute("design.colors", &Value::Null).unwrap();
|
||||
assert_eq!(scheme(&s), original);
|
||||
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
|
||||
assert_eq!(s.ui_requests.len(), 2);
|
||||
match &s.ui_requests[0] {
|
||||
UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "themeColors");
|
||||
assert_eq!(params["name"], "DeckCraft");
|
||||
let colors = params["colors"].as_object().unwrap();
|
||||
assert_eq!(colors.len(), 12);
|
||||
assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex()));
|
||||
assert!(colors.contains_key("folHlink"));
|
||||
}
|
||||
other => panic!("expected a dialog, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
|
||||
let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap();
|
||||
assert_eq!(scheme(&s).name, "Harbor");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors");
|
||||
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
|
||||
colors_dialog_rejects(&mut s, n, &harbor);
|
||||
}
|
||||
|
||||
fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
|
||||
let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no colour scheme `Nope`"), "{err}");
|
||||
assert!(s.execute("design.colors", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"name": 1})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err());
|
||||
assert_eq!(scheme(s).name, "Harbor");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(s), n + 1);
|
||||
colors_dialog_custom_overrides(s, n, harbor);
|
||||
}
|
||||
|
||||
fn colors_dialog_custom_overrides(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
|
||||
s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Custom");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1).hex(), "112233");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
|
||||
assert_eq!(undo_len(s), n + 2);
|
||||
|
||||
let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap();
|
||||
s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Meadow");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(s), n + 3);
|
||||
|
||||
s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Talk");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Meadow");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_dialog_apply_and_reject() {
|
||||
let mut s = Session::with_new();
|
||||
let original = font_scheme(&s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.fonts", &json!({})).unwrap();
|
||||
s.execute("design.fonts", &Value::Null).unwrap();
|
||||
s.execute("design.fonts", &json!([1, 2])).unwrap();
|
||||
assert_eq!(font_scheme(&s), original);
|
||||
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
|
||||
assert_eq!(s.ui_requests.len(), 3);
|
||||
match &s.ui_requests[0] {
|
||||
UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "themeFonts");
|
||||
assert_eq!(params["name"], original.name);
|
||||
assert_eq!(params["major"], original.major.latin);
|
||||
assert_eq!(params["minor"], original.minor.latin);
|
||||
}
|
||||
other => panic!("expected a dialog, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("design.fonts", &json!({"name": "classic"})).unwrap();
|
||||
let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap();
|
||||
assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
|
||||
s.execute("design.fonts", &json!({"name": "Classic"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
|
||||
|
||||
let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no font scheme `Nope`"), "{err}");
|
||||
assert!(s.execute("design.fonts", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"name": 1})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"major": ""})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"major": " "})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err());
|
||||
assert_eq!(font_scheme(&s), classic);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
fonts_dialog_custom_overrides(&mut s);
|
||||
}
|
||||
|
||||
fn fonts_dialog_custom_overrides(s: &mut Session) {
|
||||
s.edit(|doc, _| {
|
||||
let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts;
|
||||
fonts.major.ea = "EA Major".into();
|
||||
fonts.minor.cs = "CS Minor".into();
|
||||
fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() });
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let marked = font_scheme(s);
|
||||
let n = undo_len(s);
|
||||
|
||||
s.execute("design.fonts", &json!({"major": " Lato "})).unwrap();
|
||||
let custom = font_scheme(s);
|
||||
assert_eq!(custom.name, "Custom");
|
||||
assert_eq!(custom.major.latin, "Lato");
|
||||
assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major");
|
||||
assert_eq!(custom.major.ea, "EA Major");
|
||||
assert_eq!(custom.minor.cs, "CS Minor");
|
||||
assert_eq!(custom.major.scripts, marked.major.scripts);
|
||||
assert_eq!(undo_len(s), n + 1);
|
||||
|
||||
s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Custom");
|
||||
assert_eq!(font_scheme(s).major.latin, "Lato");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Roboto");
|
||||
assert_eq!(font_scheme(s).major.ea, "EA Major");
|
||||
assert_eq!(undo_len(s), n + 2);
|
||||
|
||||
s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Classic");
|
||||
assert_eq!(font_scheme(s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
|
||||
assert_eq!(font_scheme(s).major.ea, "");
|
||||
assert!(font_scheme(s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
|
||||
assert_eq!(undo_len(s), n + 3);
|
||||
|
||||
s.edit(|doc, _| {
|
||||
std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Talk");
|
||||
assert_eq!(font_scheme(s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Nunito Sans");
|
||||
assert_eq!(font_scheme(s).major.ea, "Kept");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Classic");
|
||||
assert_eq!(font_scheme(s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_picture_options_tile_transparency_and_copy() {
|
||||
let mut s = Session::with_new();
|
||||
let n = undo_len(&s);
|
||||
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
|
||||
assert!(err.contains("picture background"), "{err}");
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
|
||||
other => panic!("expected a solid background, got {other:?}"),
|
||||
}
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
|
||||
let pf = picture(&s);
|
||||
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
|
||||
let n = undo_len(&s);
|
||||
|
||||
s.execute(
|
||||
"design.background",
|
||||
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
|
||||
)
|
||||
.unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
|
||||
assert_eq!(flip, "xy");
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected a tile, got {other:?}"),
|
||||
}
|
||||
assert!(picture(&s).upright);
|
||||
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, align, .. } => {
|
||||
assert_eq!(tx, 12.0);
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected the tile to stay, got {other:?}"),
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
|
||||
other => panic!("expected a stretch, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_applies_to_master_and_new_slides_inherit() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0["background"], "#121826");
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
|
||||
|
||||
// Slide 0 has new background, master has new background, layouts cleared
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
for l in &doc.masters[0].layouts {
|
||||
assert!(l.background.is_none());
|
||||
}
|
||||
|
||||
// Newly added slides inherit the master background (fixes #891)
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
|
||||
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s0_after["background"], "#0D0D0D");
|
||||
assert_eq!(s1_after["background"], "#0D0D0D");
|
||||
|
||||
// Undo restores previous slide and master backgrounds
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
|
||||
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0_reverted["background"], "#121826");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Slide 0 follows master (background: None)
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
|
||||
// Give Slide 1 an explicit override
|
||||
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Select slide 0 (which follows master) and apply copy to all
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slide 1 has been reset to None (follows master), slide 0 remains None
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-applying copy is a no-op
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_reset_all_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Set explicit backgrounds on both slides individually (master remains untouched)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
// Now perform reset with all
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slides reset to None to follow master
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-running reset all is a no-op
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_only_touches_used_masters() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Add a second master to presentation
|
||||
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
|
||||
s.edit(|doc, _| {
|
||||
let mut m2 = (*doc.masters[0]).clone();
|
||||
m2.id = deckcraft_model::MasterId(999);
|
||||
m2.background = Some(second_master_bg.clone());
|
||||
doc.masters.push(Arc::new(m2));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
|
||||
// Slides only use master 0 (default layout)
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
// Master 0 updated
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
// Master 1 preserved!
|
||||
assert_eq!(doc.masters[1].background, Some(second_master_bg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Set all slides and master to #AA0000
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
|
||||
|
||||
// Add a layout override on layout 0
|
||||
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_bg);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.masters[0].layouts[0].background.is_none());
|
||||
|
||||
// Now that layout override is cleared, repeating is an actual no-op
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.slides[0].background.clone();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, can_redo, can_undo, cmd, has_doc, has_selection, has_slide, id_param, ids_param, ok,
|
||||
richclip, str_param, targets, xfrm_of,
|
||||
};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -318,7 +321,21 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut shapes = clip.shapes.clone();
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
let (mut taken, mut count) = super::current_names(doc, sel);
|
||||
for sh in &mut shapes {
|
||||
// Only a name already on the slide is changed (two shapes with one name are what the
|
||||
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
|
||||
if taken.contains(&sh.name)
|
||||
&& let Some((base, n)) = sh.name.rsplit_once(' ')
|
||||
&& !n.is_empty()
|
||||
&& n.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
sh.name = super::insert::first_free_name(base, &taken, count);
|
||||
}
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |m, _| {
|
||||
taken.insert(m.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
@@ -494,6 +511,19 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
paste_plain(s, &t)
|
||||
}
|
||||
|
||||
/// Duplicate gives a copy the next automatic name ("Rectangle 4" after "Rectangle 1"), as
|
||||
/// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing
|
||||
/// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}"
|
||||
/// counts as automatic.
|
||||
fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet<String>, count: usize) {
|
||||
let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) };
|
||||
let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' '));
|
||||
if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) {
|
||||
sh.name = super::insert::first_free_name(&base, taken, count);
|
||||
taken.insert(sh.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
@@ -518,6 +548,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
// Every name on the slide, group members included, so a copy never repeats one.
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
deckcraft_model::walk(&list, &mut |sh, _| {
|
||||
taken.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
walk_mut(&mut copies, &mut |c| {
|
||||
rename_automatic(c, &mut taken, count);
|
||||
count += 1;
|
||||
});
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
@@ -526,9 +567,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
}
|
||||
if !c.name.is_empty() {
|
||||
c.name = format!("{} copy", c.name.trim_end_matches(" copy"));
|
||||
}
|
||||
}
|
||||
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, opt_usize_param, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -395,6 +395,10 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(a) || !digits(b) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
@@ -536,7 +540,7 @@ fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "file.render")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
@@ -547,7 +551,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
|
||||
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "slide.colorAt")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide.colorAt", format!("no slide {i}")));
|
||||
}
|
||||
@@ -578,7 +582,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
let slides: Vec<usize> = match opt_usize_param(p, "slide", "file.export")? {
|
||||
Some(i) => vec![i],
|
||||
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
|
||||
None => vec![st.selection.slide],
|
||||
@@ -763,7 +767,14 @@ mod color_at_tests;
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::write_file;
|
||||
use super::{parse_slide_range, write_file};
|
||||
|
||||
#[test]
|
||||
fn slide_range_rejects_plus_sign() {
|
||||
assert!(parse_slide_range("+2", 3).is_err());
|
||||
assert!(parse_slide_range("+1-+3", 3).is_err());
|
||||
assert_eq!(parse_slide_range("1-3", 3).unwrap(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+37
-1178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
use super::{
|
||||
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, current_run(s).bold);
|
||||
format_run(s, &move |r| r.bold = Some(v))
|
||||
}
|
||||
|
||||
pub(super) fn italic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, current_run(s).italic);
|
||||
format_run(s, &move |r| r.italic = Some(v))
|
||||
}
|
||||
|
||||
pub(super) fn underline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = current_run(s).underline.is_some_and(|u| u != "none");
|
||||
let v = on_param(p, Some(cur));
|
||||
let style = str_param(p, "style").unwrap_or("sng").to_string();
|
||||
format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() }))
|
||||
}
|
||||
|
||||
pub(super) fn strike(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = current_run(s).strike.is_some_and(|x| x != Strike::None);
|
||||
let v = on_param(p, Some(cur));
|
||||
let d = bool_or(p, "double", false);
|
||||
format_run(s, &move |r| {
|
||||
r.strike = Some(if !v {
|
||||
Strike::None
|
||||
} else if d {
|
||||
Strike::Double
|
||||
} else {
|
||||
Strike::Single
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn superscript(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0));
|
||||
format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 }))
|
||||
}
|
||||
|
||||
pub(super) fn subscript(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0));
|
||||
format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 }))
|
||||
}
|
||||
|
||||
pub(super) fn font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string();
|
||||
format_run(s, &move |r| r.font = Some(f.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0);
|
||||
format_run(s, &move |r| r.size = Some(v))
|
||||
}
|
||||
|
||||
/// PowerPoint's font size list (grow/shrink steps through it).
|
||||
pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0];
|
||||
|
||||
fn step(cur: f64, up: bool) -> f64 {
|
||||
if up {
|
||||
SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0))
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
|
||||
// Each run steps from its own size, like PowerPoint.
|
||||
let st = s.doc()?;
|
||||
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
|
||||
let _ = st;
|
||||
let base = ctx_sizes;
|
||||
// A layout/master level steps from its own effective size, not the first run's.
|
||||
format_run_levels(
|
||||
s,
|
||||
&move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
&move |r, eff| {
|
||||
let cur = r.size.or(eff.size).unwrap_or(18.0);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
grow_shrink(s, true)
|
||||
}
|
||||
|
||||
pub(super) fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
grow_shrink(s, false)
|
||||
}
|
||||
|
||||
pub(super) fn color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
|
||||
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
|
||||
// which used to answer and change nothing.
|
||||
let st = s.doc()?;
|
||||
if st.selection.text.is_none() {
|
||||
let charts: Vec<ShapeId> =
|
||||
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
|
||||
let only_charts = charts.len() == st.selection.shapes.len();
|
||||
if !charts.is_empty() {
|
||||
let tc = c.clone();
|
||||
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.text_color = Some(tc.clone());
|
||||
ch.raw = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if only_charts {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
|
||||
}
|
||||
|
||||
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
|
||||
fn with_transparency(c: &mut ColorRef, transparency: f64) {
|
||||
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||||
if transparency > 0.0 {
|
||||
c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own
|
||||
/// fill takes the colour it shows now (from the placeholder, master or theme) so only its
|
||||
/// opacity changes.
|
||||
pub(super) fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?;
|
||||
if !v.is_finite() {
|
||||
return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1"));
|
||||
}
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let shown = match current_run(s).fill {
|
||||
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f,
|
||||
_ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)),
|
||||
};
|
||||
format_run(s, &move |r| {
|
||||
let mut f = match &r.fill {
|
||||
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(),
|
||||
Some(Fill::None) => return,
|
||||
_ => shown.clone(),
|
||||
};
|
||||
match &mut f {
|
||||
Fill::Solid { color } => with_transparency(color, v),
|
||||
Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)),
|
||||
_ => {}
|
||||
}
|
||||
r.fill = Some(f);
|
||||
})
|
||||
}
|
||||
|
||||
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
|
||||
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
|
||||
let lang = str_param(p, "lang").map(str::to_string);
|
||||
let no_proof = bool_param(p, "noProof");
|
||||
if lang.is_none() && no_proof.is_none() {
|
||||
return Err(bad(id, "give `lang` or `noProof`"));
|
||||
}
|
||||
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
|
||||
return Err(bad(id, "`lang` is a language tag such as en-US"));
|
||||
}
|
||||
Ok(move |r: &mut RunProps| {
|
||||
if let Some(l) = &lang {
|
||||
r.lang = Some(l.clone());
|
||||
}
|
||||
if let Some(n) = no_proof {
|
||||
r.no_proof = n.then_some(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Review › Language on the selected text (or the selected shapes' text).
|
||||
pub(super) fn language(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = language_edit("format.language", p)?;
|
||||
format_run(s, &f)
|
||||
}
|
||||
|
||||
/// Every run in the deck, and the text styles new text inherits from, in one language.
|
||||
pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = language_edit("format.languageAll", p)?;
|
||||
/// Runs, end marks and the body's own list style levels (never creating one).
|
||||
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
|
||||
ed::format_all(t, f);
|
||||
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
|
||||
}
|
||||
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
|
||||
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in list {
|
||||
if let Some(t) = &mut sh.text {
|
||||
body(t, f);
|
||||
}
|
||||
match &mut sh.kind {
|
||||
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
|
||||
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A master or presentation text style's levels: what new text inherits.
|
||||
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
|
||||
// Level 1 always, so a deck without a default text style gets one.
|
||||
if l.level(0).is_none() {
|
||||
l.set(0, Default::default());
|
||||
}
|
||||
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
for sl in &mut doc.slides {
|
||||
let sl = Arc::make_mut(sl);
|
||||
shapes(&mut sl.shapes, &f, 0);
|
||||
body(&mut sl.notes, &f);
|
||||
}
|
||||
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
|
||||
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
|
||||
let m = Arc::make_mut(m);
|
||||
shapes(&mut m.shapes, &f, 0);
|
||||
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
|
||||
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
|
||||
}
|
||||
styles(&mut doc.default_text_style, &f);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad("format.highlight", "`color` is not a valid colour"));
|
||||
}
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
}
|
||||
|
||||
pub(super) fn caps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = match str_param(p, "caps").unwrap_or("none") {
|
||||
"small" => Caps::Small,
|
||||
"all" => Caps::All,
|
||||
_ => Caps::None,
|
||||
};
|
||||
format_run(s, &move |r| r.caps = Some(c))
|
||||
}
|
||||
|
||||
pub(super) fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0);
|
||||
format_run(s, &move |r| r.spacing = Some(v))
|
||||
}
|
||||
|
||||
/// Character formatting applied together, so the Font dialog is one undo step.
|
||||
struct FontEdits {
|
||||
family: Option<String>,
|
||||
size: Option<f64>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
underline: Option<String>,
|
||||
color: Option<deckcraft_model::ColorRef>,
|
||||
strike: Option<Strike>,
|
||||
baseline: Option<f64>,
|
||||
caps: Option<Caps>,
|
||||
spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl FontEdits {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.family.is_none()
|
||||
&& self.size.is_none()
|
||||
&& self.bold.is_none()
|
||||
&& self.italic.is_none()
|
||||
&& self.underline.is_none()
|
||||
&& self.color.is_none()
|
||||
&& self.strike.is_none()
|
||||
&& self.baseline.is_none()
|
||||
&& self.caps.is_none()
|
||||
&& self.spacing.is_none()
|
||||
}
|
||||
|
||||
fn write(&self, r: &mut RunProps) {
|
||||
if let Some(f) = &self.family {
|
||||
r.font = Some(f.clone());
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
r.size = Some(v);
|
||||
}
|
||||
if let Some(v) = self.bold {
|
||||
r.bold = Some(v);
|
||||
}
|
||||
if let Some(v) = self.italic {
|
||||
r.italic = Some(v);
|
||||
}
|
||||
if let Some(u) = &self.underline {
|
||||
r.underline = Some(u.clone());
|
||||
}
|
||||
if let Some(c) = &self.color {
|
||||
r.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
if let Some(v) = self.strike {
|
||||
r.strike = Some(v);
|
||||
}
|
||||
if let Some(v) = self.baseline {
|
||||
r.baseline = Some(v);
|
||||
}
|
||||
if let Some(v) = self.caps {
|
||||
r.caps = Some(v);
|
||||
}
|
||||
if let Some(v) = self.spacing {
|
||||
r.spacing = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_bool(p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_str<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_edits(p: &Value) -> Result<FontEdits> {
|
||||
let family = match font_str(p, "family")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let t = s.trim();
|
||||
if t.is_empty() || t.chars().count() > 200 {
|
||||
return Err(bad("format.fontDialog", "`family` must be 1–200 characters"));
|
||||
}
|
||||
Some(t.to_string())
|
||||
}
|
||||
};
|
||||
let size = match p.get("size") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?;
|
||||
Some(n.clamp(1.0, 4000.0))
|
||||
}
|
||||
};
|
||||
let underline = match font_str(p, "underline")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") {
|
||||
return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy"));
|
||||
}
|
||||
Some(s.to_string())
|
||||
}
|
||||
};
|
||||
let color =
|
||||
if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None };
|
||||
let strike = match font_str(p, "strike")? {
|
||||
None => None,
|
||||
Some("none") => Some(Strike::None),
|
||||
Some("sng" | "single") => Some(Strike::Single),
|
||||
Some("dbl" | "double") => Some(Strike::Double),
|
||||
Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")),
|
||||
};
|
||||
let baseline = match p.get("baseline") {
|
||||
None => None,
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"none" => Some(0.0),
|
||||
"super" | "superscript" => Some(0.3),
|
||||
"sub" | "subscript" => Some(-0.25),
|
||||
_ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
|
||||
},
|
||||
Some(Value::Number(n)) => {
|
||||
let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?;
|
||||
Some(f.clamp(-1.0, 1.0))
|
||||
}
|
||||
Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
|
||||
};
|
||||
let caps = match font_str(p, "caps")? {
|
||||
None => None,
|
||||
Some("none") => Some(Caps::None),
|
||||
Some("small") => Some(Caps::Small),
|
||||
Some("all") => Some(Caps::All),
|
||||
Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")),
|
||||
};
|
||||
let spacing = match p.get("spacing") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?;
|
||||
Some(n.clamp(-100.0, 400.0))
|
||||
}
|
||||
};
|
||||
Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing })
|
||||
}
|
||||
|
||||
/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms.
|
||||
fn color_command_value(c: &deckcraft_model::ColorRef) -> Value {
|
||||
use deckcraft_model::ColorBase;
|
||||
let mut o = serde_json::Map::new();
|
||||
for m in &c.mods {
|
||||
if let Some(v) = m.value() {
|
||||
o.insert(m.xml_name().to_string(), json!(v));
|
||||
}
|
||||
}
|
||||
match &c.base {
|
||||
ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())),
|
||||
ColorBase::Rgb { rgb } => {
|
||||
o.insert("rgb".into(), json!(format!("#{}", rgb.hex())));
|
||||
Value::Object(o)
|
||||
}
|
||||
ColorBase::Scheme { slot } => {
|
||||
o.insert("scheme".into(), json!(slot.xml_name()));
|
||||
Value::Object(o)
|
||||
}
|
||||
ColorBase::Preset { name } => json!(name),
|
||||
ColorBase::System { last, .. } => json!(format!("#{}", last.hex())),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_dialog_params(s: &Session) -> Value {
|
||||
let r = current_run(s);
|
||||
let family =
|
||||
s.doc().ok().and_then(|st| crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default();
|
||||
let baseline = r.baseline.unwrap_or(0.0);
|
||||
let mut v = json!({
|
||||
"family": family,
|
||||
"size": r.size.unwrap_or(18.0),
|
||||
"bold": r.bold.unwrap_or(false),
|
||||
"italic": r.italic.unwrap_or(false),
|
||||
"underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"),
|
||||
"strike": match r.strike {
|
||||
Some(Strike::Double) => "dbl",
|
||||
Some(Strike::Single) => "sng",
|
||||
_ => "none",
|
||||
},
|
||||
"baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" },
|
||||
"caps": match r.caps {
|
||||
Some(Caps::Small) => "small",
|
||||
Some(Caps::All) => "all",
|
||||
_ => "none",
|
||||
},
|
||||
"spacing": r.spacing.unwrap_or(0.0),
|
||||
});
|
||||
if let Some(Fill::Solid { color }) = &r.fill {
|
||||
v["color"] = color_command_value(color);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) fn font_dialog(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edits = font_edits(p)?;
|
||||
if edits.is_empty() {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) });
|
||||
return ok();
|
||||
}
|
||||
format_run(s, &|r| edits.write(r))
|
||||
}
|
||||
|
||||
pub(super) fn clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() })
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use super::{
|
||||
Bullet, Effects, Fill, Line, ParaProps, Result, RunProps, Session, Shape, Strike, Value, bad, base64_decode, bool_or, current_para, current_run,
|
||||
ed, edit_shapes, format_run, ids_param, json, ok, resolve, str_param,
|
||||
};
|
||||
|
||||
/// Formatting picked up by the Format Painter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Painted {
|
||||
pub run: Option<RunProps>,
|
||||
pub para: Option<ParaProps>,
|
||||
pub fill: Option<Fill>,
|
||||
pub line: Option<Line>,
|
||||
pub effects: Option<Effects>,
|
||||
pub style: Option<deckcraft_model::ShapeStyle>,
|
||||
}
|
||||
|
||||
pub(super) fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut painted = Painted::default();
|
||||
if let Some(t) = &st.selection.text {
|
||||
if let Some(b) = crate::cmd::text::body_of(st, t) {
|
||||
let a = t.ordered().0;
|
||||
painted.run = Some(ed::props_at(b, (a.0, a.1 + 1)));
|
||||
painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone());
|
||||
}
|
||||
} else if let Some(sh) = st.selected_shapes().first() {
|
||||
painted.fill = sh.fill.clone();
|
||||
painted.line = sh.line.clone();
|
||||
painted.effects = sh.effects.clone();
|
||||
painted.style = sh.style.clone();
|
||||
painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone());
|
||||
}
|
||||
s.painter = Some((painted, bool_or(p, "sticky", false)));
|
||||
Ok(json!({"armed": true}))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
|
||||
if p.fill.is_some() || p.style.is_some() {
|
||||
sh.fill.clone_from(&p.fill);
|
||||
sh.line.clone_from(&p.line);
|
||||
sh.effects.clone_from(&p.effects);
|
||||
sh.style.clone_from(&p.style);
|
||||
}
|
||||
if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) {
|
||||
ed::format_all(t, &|rp| *rp = r.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraphs the text selection covers whole: it starts at or before their start and
|
||||
/// reaches their end. A paragraph the selection only touches the start of (it ends at offset
|
||||
/// 0, as after a triple-click) is not covered.
|
||||
fn covered_paragraphs(s: &Session) -> Vec<usize> {
|
||||
let Ok(st) = s.doc() else { return vec![] };
|
||||
let Some(t) = &st.selection.text else { return vec![] };
|
||||
let Some(body) = crate::cmd::text::body_of(st, t) else { return vec![] };
|
||||
let (a, b) = t.ordered();
|
||||
// A hostile selection can point past the body: walk only the paragraphs it has.
|
||||
(a.0..=b.0.min(body.paragraphs.len().saturating_sub(1)))
|
||||
.filter(|&pi| {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return false };
|
||||
let starts = pi > a.0 || a.1 == 0;
|
||||
let ends = if pi < b.0 {
|
||||
true
|
||||
} else if b.0 > a.0 && b.1 == 0 {
|
||||
false
|
||||
} else {
|
||||
b.1 >= para.char_len()
|
||||
};
|
||||
starts && ends
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
|
||||
if !sticky {
|
||||
s.painter = None;
|
||||
}
|
||||
if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() {
|
||||
if let Some(r) = painted.run.clone() {
|
||||
format_run(s, &move |rp| *rp = r.clone())?;
|
||||
}
|
||||
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
|
||||
// selection covers whole; painting part of a bulleted line changes just its text.
|
||||
let covered = covered_paragraphs(s);
|
||||
if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) {
|
||||
crate::cmd::text::edit_text(s, |body, _| {
|
||||
for &pi in &covered {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
para.props = pp.clone();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
edit_shapes(s, p, "format.painterApply", |sh| {
|
||||
apply_painted(sh, &painted);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn state(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let r = current_run(s);
|
||||
let pp = current_para(s);
|
||||
let st = s.doc()?;
|
||||
let family = crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default();
|
||||
Ok(json!({
|
||||
"font": family,
|
||||
"fontRef": r.font,
|
||||
"size": r.size,
|
||||
"bold": r.bold.unwrap_or(false),
|
||||
"italic": r.italic.unwrap_or(false),
|
||||
"underline": r.underline.as_deref().is_some_and(|u| u != "none"),
|
||||
"strike": r.strike.is_some_and(|x| x != Strike::None),
|
||||
"superscript": r.baseline.unwrap_or(0.0) > 0.0,
|
||||
"subscript": r.baseline.unwrap_or(0.0) < 0.0,
|
||||
"align": pp.align.map(|a| a.xml()),
|
||||
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
|
||||
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
|
||||
"lang": r.lang,
|
||||
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
|
||||
"painter": s.painter.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Add a font from its bytes for this session (it then shows in the font menu and draws).
|
||||
pub(super) fn font_add(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?;
|
||||
if data.len() > 64 << 20 {
|
||||
return Err(bad("font.add", "font file larger than 64 MB"));
|
||||
}
|
||||
let faces = deckcraft_fonts::FontDb::global().add_font(data);
|
||||
if faces == 0 {
|
||||
return Err(bad("font.add", "not a font DeckCraft can read (or already added)"));
|
||||
}
|
||||
Ok(json!({"faces": faces}))
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear.
|
||||
pub(super) fn font_rescan(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let db = deckcraft_fonts::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0usize;
|
||||
Ok(json!({"faces": faces, "families": db.families().len()}))
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
use super::{
|
||||
Align, Anchor, AutoFit, Bullet, Result, Session, Spacing, TextDir, Value, bad, bool_or, color_param, current_para, f64_or, f64_param,
|
||||
format_body, format_para, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn align_to(s: &mut Session, a: Align) -> Result<Value> {
|
||||
format_para(s, &move |p| p.props.align = Some(a))
|
||||
}
|
||||
|
||||
pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = match str_param(p, "align").unwrap_or("left") {
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
}
|
||||
|
||||
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
|
||||
/// at the start edge stays there.
|
||||
pub(super) fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = on_param(p, current_para(s).rtl);
|
||||
format_para(s, &move |para| {
|
||||
if para.props.rtl.unwrap_or(false) != on {
|
||||
para.props.align = match para.props.align {
|
||||
None | Some(Align::Left) => Some(Align::Right),
|
||||
Some(Align::Right) => Some(Align::Left),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
para.props.rtl = Some(on);
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
|
||||
let on = on_param(p, Some(cur));
|
||||
let ch = str_param(p, "char").unwrap_or("•").to_string();
|
||||
let col = color_param(p, "color");
|
||||
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
|
||||
let explicit_char = p.get("char").is_some();
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
|
||||
if explicit_char {
|
||||
para.props.bullet_font = Some("Arial".into());
|
||||
para.props.bullet_font_follow_text = None;
|
||||
}
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
|
||||
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-18.0);
|
||||
}
|
||||
} else {
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.props.indent = Some(0.0);
|
||||
para.props.margin_left = Some(36.0 * para.level as f64);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. }));
|
||||
let on = on_param(p, Some(cur));
|
||||
let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1);
|
||||
let col = color_param(p, "color");
|
||||
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-27.0);
|
||||
} else {
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.props.indent = Some(0.0);
|
||||
para.props.margin_left = Some(36.0 * para.level as f64);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn level(s: &mut Session, d: i32) -> Result<Value> {
|
||||
format_para(s, &move |p| {
|
||||
let l = (p.level as i32 + d).clamp(0, 8) as u8;
|
||||
// Explicit indents shift with the level.
|
||||
if l != p.level {
|
||||
if let Some(m) = p.props.margin_left.as_mut() {
|
||||
*m = (*m + 36.0 * d as f64).max(0.0);
|
||||
}
|
||||
p.level = l;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sp =
|
||||
if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) };
|
||||
format_para(s, &move |para| para.props.line_spacing = Some(sp))
|
||||
}
|
||||
|
||||
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
let sb = f64_param(p, "spaceBefore");
|
||||
let sa = f64_param(p, "spaceAfter");
|
||||
let ls = f64_param(p, "lineSpacing");
|
||||
let lsp = f64_param(p, "lineSpacingPt");
|
||||
format_para(s, &move |para| {
|
||||
if a.is_some() {
|
||||
para.props.align = a;
|
||||
}
|
||||
if ml.is_some() {
|
||||
para.props.margin_left = ml.map(|v| v.max(0.0));
|
||||
}
|
||||
if ind.is_some() {
|
||||
para.props.indent = ind;
|
||||
}
|
||||
if let Some(v) = sb {
|
||||
para.props.space_before = Some(Spacing::Pts(v.max(0.0)));
|
||||
}
|
||||
if let Some(v) = sa {
|
||||
para.props.space_after = Some(Spacing::Pts(v.max(0.0)));
|
||||
}
|
||||
if let Some(v) = ls {
|
||||
para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99)));
|
||||
}
|
||||
if let Some(v) = lsp {
|
||||
para.props.line_spacing = Some(Spacing::Pts(v.max(0.0)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const PARA_DIALOG_KEYS: &[&str] =
|
||||
&["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"];
|
||||
|
||||
fn para_dialog_has_fields(p: &Value) -> bool {
|
||||
PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some())
|
||||
}
|
||||
|
||||
pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?;
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clamp_pt(v: f64) -> f64 {
|
||||
v.clamp(0.0, 1584.0)
|
||||
}
|
||||
|
||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
|
||||
})
|
||||
}
|
||||
|
||||
fn align_name(a: Align) -> &'static str {
|
||||
match a {
|
||||
Align::Left => "left",
|
||||
Align::Center => "center",
|
||||
Align::Right => "right",
|
||||
Align::Justify => "justify",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "distributed",
|
||||
}
|
||||
}
|
||||
|
||||
fn spacing_parts(s: Option<Spacing>) -> (&'static str, f64) {
|
||||
match s {
|
||||
Some(Spacing::Pct(v)) => ("pct", v),
|
||||
Some(Spacing::Pts(v)) => ("pts", v),
|
||||
None => ("pts", 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_dialog_params(s: &Session) -> Value {
|
||||
let pp = current_para(s);
|
||||
let indent = pp.indent.unwrap_or(0.0);
|
||||
let (special, by) = if indent > 0.05 {
|
||||
("first", indent)
|
||||
} else if indent < -0.05 {
|
||||
("hanging", -indent)
|
||||
} else {
|
||||
("none", 0.0)
|
||||
};
|
||||
let (line_kind, line_value) = match pp.line_spacing {
|
||||
Some(Spacing::Pts(v)) => ("exactly", v),
|
||||
Some(Spacing::Pct(v)) => ("multiple", v),
|
||||
None => ("multiple", 1.0),
|
||||
};
|
||||
let (before_kind, before) = spacing_parts(pp.space_before);
|
||||
let (after_kind, after) = spacing_parts(pp.space_after);
|
||||
let tabs: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
|
||||
json!({
|
||||
"align": align_name(pp.align.unwrap_or(Align::Left)),
|
||||
"indentLeft": pp.margin_left.unwrap_or(0.0),
|
||||
"indentRight": pp.margin_right.unwrap_or(0.0),
|
||||
"special": special,
|
||||
"specialBy": by,
|
||||
"spaceBefore": before,
|
||||
"spaceBeforeKind": before_kind,
|
||||
"spaceAfter": after,
|
||||
"spaceAfterKind": after_kind,
|
||||
"lineKind": line_kind,
|
||||
"lineValue": line_value,
|
||||
"tabs": tabs,
|
||||
"defaultTab": pp.default_tab.unwrap_or(72.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_spacing(kind: Option<&str>, value: f64) -> Spacing {
|
||||
if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) }
|
||||
}
|
||||
|
||||
pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.paragraphDialog";
|
||||
if !para_dialog_has_fields(p) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) });
|
||||
return ok();
|
||||
}
|
||||
let align = match p.get("align") {
|
||||
None => None,
|
||||
Some(Value::String(a)) => Some(parse_align(CMD, a)?),
|
||||
Some(_) => return Err(bad(CMD, "`align` must be a string")),
|
||||
};
|
||||
let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt);
|
||||
let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt);
|
||||
let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0)));
|
||||
let indent = match p.get("special") {
|
||||
None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)),
|
||||
Some(Value::String(kind)) => {
|
||||
let by = special_by.unwrap_or(0.0);
|
||||
Some(match kind.as_str() {
|
||||
"none" => 0.0,
|
||||
"first" | "firstLine" => by,
|
||||
"hanging" | "hang" => -by,
|
||||
_ => return Err(bad(CMD, "`special` must be none, first or hanging")),
|
||||
})
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")),
|
||||
};
|
||||
let space_before = finite_key(p, CMD, "spaceBefore")?;
|
||||
let space_after = finite_key(p, CMD, "spaceAfter")?;
|
||||
let before_kind = str_param(p, "spaceBeforeKind");
|
||||
let after_kind = str_param(p, "spaceAfterKind");
|
||||
for kind in [before_kind, after_kind].into_iter().flatten() {
|
||||
if !matches!(kind, "pts" | "pct") {
|
||||
return Err(bad(CMD, "spacing kind must be pts or pct"));
|
||||
}
|
||||
}
|
||||
let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99));
|
||||
let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt);
|
||||
format_para(s, &move |para| {
|
||||
if align.is_some() {
|
||||
para.props.align = align;
|
||||
}
|
||||
if indent_left.is_some() {
|
||||
para.props.margin_left = indent_left;
|
||||
}
|
||||
if indent_right.is_some() {
|
||||
para.props.margin_right = indent_right;
|
||||
}
|
||||
if indent.is_some() {
|
||||
para.props.indent = indent;
|
||||
}
|
||||
if let Some(v) = space_before {
|
||||
para.props.space_before = Some(write_spacing(before_kind, v));
|
||||
}
|
||||
if let Some(v) = space_after {
|
||||
para.props.space_after = Some(write_spacing(after_kind, v));
|
||||
}
|
||||
if let Some(v) = line_mult {
|
||||
para.props.line_spacing = Some(Spacing::Pct(v));
|
||||
}
|
||||
if let Some(v) = line_pt {
|
||||
para.props.line_spacing = Some(Spacing::Pts(v));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tab_align(s: &str) -> Option<&'static str> {
|
||||
Some(match s {
|
||||
"l" | "left" => "l",
|
||||
"ctr" | "center" => "ctr",
|
||||
"r" | "right" => "r",
|
||||
"dec" | "decimal" => "dec",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tabs_params(s: &Session) -> Value {
|
||||
let pp = current_para(s);
|
||||
let stops: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
|
||||
json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)})
|
||||
}
|
||||
|
||||
pub(super) fn anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = match str_param(p, "anchor").unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
_ => Anchor::Top,
|
||||
};
|
||||
format_body(s, &move |b| b.anchor = Some(a))
|
||||
}
|
||||
|
||||
pub(super) fn direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = match str_param(p, "dir").unwrap_or("horizontal") {
|
||||
"rotate90" | "vert" => TextDir::Vertical,
|
||||
"rotate270" | "vert270" => TextDir::Vertical270,
|
||||
"stacked" => TextDir::Stacked,
|
||||
_ => TextDir::Horizontal,
|
||||
};
|
||||
format_body(s, &move |b| b.vert = Some(d))
|
||||
}
|
||||
|
||||
/// The edited or first selected shape's columns as drawn: the count and the spacing in points.
|
||||
fn current_columns(s: &Session) -> (u32, f64) {
|
||||
let Ok(st) = s.doc() else { return (1, 0.0) };
|
||||
let id = match &st.selection.text {
|
||||
Some(t) if !t.notes => Some(t.shape),
|
||||
_ => st.selection.shapes.first().copied(),
|
||||
};
|
||||
let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) };
|
||||
let body = match crate::cmd::text::ctx_for(&st.doc, &st.selection) {
|
||||
Some(c) => resolve::body(&c, sh),
|
||||
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
|
||||
};
|
||||
(body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0))
|
||||
}
|
||||
|
||||
pub(super) fn columns(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.columns";
|
||||
let count = match p.get("count") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n))
|
||||
.ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?;
|
||||
Some(n as u32)
|
||||
}
|
||||
};
|
||||
let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt);
|
||||
if count.is_none() && spacing.is_none() {
|
||||
let (count, spacing) = current_columns(s);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) });
|
||||
return ok();
|
||||
}
|
||||
format_body(s, &move |b| {
|
||||
if let Some(n) = count {
|
||||
b.columns = Some(n);
|
||||
}
|
||||
if let Some(sp) = spacing {
|
||||
b.col_spacing = Some(sp);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = match str_param(p, "mode").unwrap_or("none") {
|
||||
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
|
||||
"resize" | "shape" => AutoFit::Shape,
|
||||
_ => AutoFit::None,
|
||||
};
|
||||
format_body(s, &move |b| b.autofit = Some(m))
|
||||
}
|
||||
|
||||
pub(super) fn wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = bool_or(p, "on", true);
|
||||
format_body(s, &move |b| b.wrap = Some(on))
|
||||
}
|
||||
|
||||
pub(super) fn margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom"));
|
||||
format_body(s, &move |bp| {
|
||||
if l.is_some() {
|
||||
bp.inset_l = l.map(|v| v.max(0.0));
|
||||
}
|
||||
if t.is_some() {
|
||||
bp.inset_t = t.map(|v| v.max(0.0));
|
||||
}
|
||||
if r.is_some() {
|
||||
bp.inset_r = r.map(|v| v.max(0.0));
|
||||
}
|
||||
if b.is_some() {
|
||||
bp.inset_b = b.map(|v| v.max(0.0));
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params};
|
||||
use super::{Result, Session, TabStop, TextDir, Value, bad, bool_or, current_para, format_para, json, ok, resolve};
|
||||
|
||||
/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops,
|
||||
/// and where its text area sits on the slide.
|
||||
pub(super) fn ruler(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let props = current_para(s);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?;
|
||||
let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) {
|
||||
(false, Some(sh)) => {
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
|
||||
let x = match &ctx {
|
||||
Some(c) => resolve::xfrm(c, sh),
|
||||
None => sh.xfrm.unwrap_or_default(),
|
||||
};
|
||||
let body = match &ctx {
|
||||
Some(c) => resolve::body(c, sh),
|
||||
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
|
||||
};
|
||||
let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6));
|
||||
(x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal))
|
||||
}
|
||||
_ => (0.0, 0.0, 0.0, true),
|
||||
};
|
||||
let tabs: Vec<Value> = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
|
||||
Ok(json!({
|
||||
"x": x, "y": y, "width": w, "rotated": rotated,
|
||||
"marginLeft": props.margin_left.unwrap_or(0.0),
|
||||
"indent": props.indent.unwrap_or(0.0),
|
||||
"tabs": tabs,
|
||||
"defaultTab": props.default_tab.unwrap_or(72.0),
|
||||
}))
|
||||
}
|
||||
|
||||
const MAX_TABS: usize = 32;
|
||||
|
||||
pub(super) fn tabs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.tabs";
|
||||
let fin = |v: Option<f64>| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0));
|
||||
let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") {
|
||||
"ctr" | "center" => "ctr",
|
||||
"r" | "right" => "r",
|
||||
"dec" | "decimal" => "dec",
|
||||
_ => "l",
|
||||
};
|
||||
let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string())));
|
||||
let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?)));
|
||||
let remove = fin(p.get("remove").and_then(Value::as_f64));
|
||||
// `set` replaces every stop: entries without a usable position are dropped, an unknown
|
||||
// alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins).
|
||||
let set = match p.get("set") {
|
||||
None => None,
|
||||
Some(Value::Array(items)) => {
|
||||
if items.len() > 64 {
|
||||
return Err(bad(CMD, "`set` has more than 64 tab stops"));
|
||||
}
|
||||
let mut out: Vec<TabStop> = Vec::new();
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let Some(obj) = item.as_object() else {
|
||||
return Err(bad(CMD, format!("tab stop {i} must be an object")));
|
||||
};
|
||||
let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else {
|
||||
continue;
|
||||
};
|
||||
let align = match obj.get("align").and_then(Value::as_str) {
|
||||
None => "l",
|
||||
Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?,
|
||||
};
|
||||
if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) {
|
||||
*prev = TabStop { pos, align: align.to_string() };
|
||||
} else {
|
||||
out.push(TabStop { pos, align: align.to_string() });
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`set` must be an array")),
|
||||
};
|
||||
let clear = bool_or(p, "clear", false);
|
||||
let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0));
|
||||
let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear;
|
||||
if !edits_stops && default_tab.is_none() {
|
||||
// No fields: open the Tabs dialog on the caret's paragraph.
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) });
|
||||
return ok();
|
||||
}
|
||||
// The stops as the caret's paragraph shows them (inherited ones included), so editing a
|
||||
// placeholder's tab that came from its layout works like editing one set on the paragraph.
|
||||
let base = current_para(s).tabs.unwrap_or_default();
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 0.5;
|
||||
let mut stops = if clear { vec![] } else { set.unwrap_or(base) };
|
||||
if let Some(at) = remove {
|
||||
stops.retain(|t| !near(t.pos, at));
|
||||
}
|
||||
if let Some((from, to)) = mv
|
||||
&& let Some(t) = stops.iter_mut().find(|t| near(t.pos, from))
|
||||
{
|
||||
t.pos = to;
|
||||
}
|
||||
if let Some((pos, align)) = add {
|
||||
stops.retain(|t| !near(t.pos, pos));
|
||||
stops.push(TabStop { pos, align });
|
||||
}
|
||||
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
stops.dedup_by(|a, b| near(a.pos, b.pos));
|
||||
stops.truncate(MAX_TABS);
|
||||
format_para(s, &move |para| {
|
||||
if edits_stops {
|
||||
para.props.tabs = Some(stops.clone());
|
||||
}
|
||||
if let Some(d) = default_tab {
|
||||
para.props.default_tab = Some(d);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::{
|
||||
Line, Result, Session, Value, bad, color_param, current_run, ed, edit_shapes, f64_or, format_body, format_run, json, ok, on_param, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).shadow.is_some()));
|
||||
let sh = deckcraft_model::style::Shadow {
|
||||
color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)),
|
||||
blur: 3.0,
|
||||
dist: 2.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None })
|
||||
}
|
||||
|
||||
pub(super) fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
let w = f64_or(p, "width", 0.75);
|
||||
format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w)))
|
||||
}
|
||||
|
||||
pub(super) fn text_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?;
|
||||
let clear = preset == "none" || preset == "textNoShape";
|
||||
if !clear && !deckcraft_geom::warp::is_supported(preset) {
|
||||
return Err(bad("format.textWarp", format!("unknown text transform `{preset}`")));
|
||||
}
|
||||
let adj = match p.get("adj") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(arr)) => {
|
||||
if arr.len() > 8 {
|
||||
return Err(bad("format.textWarp", "at most 8 adjust values"));
|
||||
}
|
||||
let mut v = Vec::with_capacity(arr.len());
|
||||
for a in arr {
|
||||
let Some(n) = a.as_f64().filter(|n| n.is_finite()) else {
|
||||
return Err(bad("format.textWarp", "adjust values must be finite numbers"));
|
||||
};
|
||||
v.push(n.clamp(-2.0e9, 2.0e9));
|
||||
}
|
||||
v
|
||||
}
|
||||
Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")),
|
||||
};
|
||||
let stored = if clear { None } else { Some(preset.to_string()) };
|
||||
format_body(s, &|b| {
|
||||
b.warp.clone_from(&stored);
|
||||
b.warp_adj.clone_from(&adj);
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (mut a, mut b) = t.ordered();
|
||||
let body = crate::cmd::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||
let collapsed = a == b;
|
||||
if collapsed {
|
||||
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||
return ok();
|
||||
}
|
||||
(a, b) = (wa, wb);
|
||||
}
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
crate::cmd::text::edit_text(s, move |body, t| {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { n };
|
||||
let mut pos = 0;
|
||||
let mut pieces = Vec::new();
|
||||
for (ri, r) in para.runs.iter().enumerate() {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let len = r.char_len();
|
||||
let rs = from.max(pos).saturating_sub(pos);
|
||||
let re = to.min(pos + len).saturating_sub(pos);
|
||||
if re > rs {
|
||||
pieces.push((ri, rs, re));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
let mids: Vec<String> = pieces
|
||||
.iter()
|
||||
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
|
||||
.collect();
|
||||
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
|
||||
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
|
||||
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
|
||||
let pre: String = r.text.chars().take(rs).collect();
|
||||
let post: String = r.text.chars().skip(re).collect();
|
||||
r.text = format!("{pre}{new}{post}");
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let mode = if mode == "cycle" {
|
||||
let all: String =
|
||||
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
ed::next_case(&all).to_string()
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
|
||||
let changed = ed::change_case_parts(&texts, &mode);
|
||||
for (r, new) in para.runs.iter_mut().zip(changed) {
|
||||
if r.kind == deckcraft_model::text::RunKind::Text {
|
||||
r.text = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, cmd, has_slide, opt_usize_param, str_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -245,13 +245,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
|
||||
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "design.ideas")?.unwrap_or(st.selection.slide);
|
||||
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
|
||||
let slide = usize_param(p, "slide");
|
||||
let slide = opt_usize_param(p, "slide", "design.applyIdea")?;
|
||||
s.edit(|doc, sel| {
|
||||
let i = slide.unwrap_or(sel.slide);
|
||||
apply_idea(doc, sel, i, &id)?;
|
||||
@@ -372,6 +372,19 @@ mod tests {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_slide_index_is_refused_not_replaced_by_the_selection() {
|
||||
let mut s = deck(false);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for bad_slide in [json!(-1), json!(1.5), json!("0")] {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
assert!(s.execute("design.ideas", &json!({"slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc) || *before == *s.doc().unwrap().doc);
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": 0})).is_ok());
|
||||
assert!(s.execute("design.ideas", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
|
||||
+125
-1828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,724 @@
|
||||
use super::picture::{content_type, media_bytes};
|
||||
use super::{NO_ID_SPACE, Result, Session, ShapeKind, Value, bad, base64_encode, json};
|
||||
|
||||
const ALBUM_MAX_ITEMS: usize = 48;
|
||||
|
||||
const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024;
|
||||
|
||||
const ALBUM_MAX_TEXT: usize = 400;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AlbumLayout {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
per: usize,
|
||||
title: bool,
|
||||
fit: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AlbumFrame {
|
||||
None,
|
||||
Rect,
|
||||
Rounded,
|
||||
Simple,
|
||||
}
|
||||
|
||||
struct AlbumPic {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
px_w: f64,
|
||||
px_h: f64,
|
||||
rot: f64,
|
||||
brightness: f64,
|
||||
contrast: f64,
|
||||
}
|
||||
|
||||
struct AlbumOpts {
|
||||
layout_key: String,
|
||||
frame_key: String,
|
||||
layout: AlbumLayout,
|
||||
frame: AlbumFrame,
|
||||
captions: bool,
|
||||
grayscale: bool,
|
||||
theme: Option<String>,
|
||||
}
|
||||
|
||||
enum AlbumItem {
|
||||
Picture(AlbumPic),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
enum AlbumSlide {
|
||||
Pictures(Vec<AlbumPic>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Insert a photo album after the current slide.
|
||||
///
|
||||
/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one
|
||||
/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit.
|
||||
pub(super) fn photo_album(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("pictures").is_none_or(Value::is_null) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) });
|
||||
return Ok(json!({"dialog": "photoAlbum"}));
|
||||
}
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
commit_album(s, items, opts, None)
|
||||
}
|
||||
|
||||
pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("pictures").is_none_or(Value::is_null) {
|
||||
let seed = album_seed(s)?;
|
||||
let album = seed.get("album").cloned().unwrap_or(Value::Null);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed });
|
||||
return Ok(json!({"dialog": "photoAlbum", "album": album}));
|
||||
}
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (id, ids) = album_target(s, p)?;
|
||||
commit_album(s, items, opts, Some((id, ids)))
|
||||
}
|
||||
|
||||
fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace: Option<(u32, Vec<u64>)>) -> Result<Value> {
|
||||
let (snapshot, sel) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.selection.clone())
|
||||
};
|
||||
let result = (|| {
|
||||
let album_id = match replace.as_ref().map(|(id, _)| *id) {
|
||||
Some(id) if id != 0 => id,
|
||||
_ => {
|
||||
let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" };
|
||||
s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))?
|
||||
}
|
||||
};
|
||||
let at = if let Some((_, ids)) = &replace {
|
||||
let st = s.doc()?;
|
||||
let indices: Vec<usize> = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect();
|
||||
let Some(at) = indices.iter().copied().min() else {
|
||||
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
|
||||
};
|
||||
s.execute("slide.delete", &json!({"ids": ids}))?;
|
||||
Some(at)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut opts = opts;
|
||||
if opts.theme.as_deref().unwrap_or("").is_empty() {
|
||||
opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty());
|
||||
}
|
||||
apply_theme_if_needed(s, opts.theme.as_deref())?;
|
||||
insert_album(s, items, &opts, at, album_id)
|
||||
})();
|
||||
if result.is_err()
|
||||
&& let Some(st) = s.active_mut()
|
||||
{
|
||||
st.doc = snapshot;
|
||||
st.selection = sel;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
let key = match p.get("layout") {
|
||||
None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")),
|
||||
};
|
||||
match album_norm(key).as_str() {
|
||||
"fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }),
|
||||
"1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
|
||||
"1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }),
|
||||
"2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }),
|
||||
"2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }),
|
||||
"4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }),
|
||||
"4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }),
|
||||
_ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_frame(p: &Value) -> Result<AlbumFrame> {
|
||||
let key = match p.get("frame") {
|
||||
None => return Ok(AlbumFrame::None),
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")),
|
||||
};
|
||||
match album_norm(key).as_str() {
|
||||
"none" => Ok(AlbumFrame::None),
|
||||
"rect" | "rectangle" => Ok(AlbumFrame::Rect),
|
||||
"rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded),
|
||||
"simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple),
|
||||
_ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_captions(p: &Value) -> Result<bool> {
|
||||
match p.get("captions") {
|
||||
None => Ok(false),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_bool(p: &Value, key: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_key_of(layout: AlbumLayout) -> String {
|
||||
if layout.fit {
|
||||
return "fit".into();
|
||||
}
|
||||
let n = match layout.per {
|
||||
2 => "2",
|
||||
4 => "4",
|
||||
_ => "1",
|
||||
};
|
||||
if layout.title { format!("{n}title") } else { n.into() }
|
||||
}
|
||||
|
||||
fn frame_key_of(frame: AlbumFrame) -> &'static str {
|
||||
match frame {
|
||||
AlbumFrame::None => "none",
|
||||
AlbumFrame::Rect => "rect",
|
||||
AlbumFrame::Rounded => "rounded",
|
||||
AlbumFrame::Simple => "simple",
|
||||
}
|
||||
}
|
||||
|
||||
fn album_opts(p: &Value) -> Result<AlbumOpts> {
|
||||
let layout = album_layout(p)?;
|
||||
let frame = album_frame(p)?;
|
||||
let theme = match p.get("theme") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) if s.trim().is_empty() => None,
|
||||
Some(Value::String(s)) => {
|
||||
let name = s.trim();
|
||||
let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name));
|
||||
let Some(t) = found else {
|
||||
return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`")));
|
||||
};
|
||||
Some(t.name)
|
||||
}
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")),
|
||||
};
|
||||
Ok(AlbumOpts {
|
||||
layout_key: layout_key_of(layout),
|
||||
frame_key: frame_key_of(frame).into(),
|
||||
layout,
|
||||
frame,
|
||||
captions: album_captions(p)?,
|
||||
grayscale: album_bool(p, "grayscale")?,
|
||||
theme,
|
||||
})
|
||||
}
|
||||
|
||||
/// A finite number, or 0 when the key is absent. A present non-number is an error.
|
||||
fn album_finite(item: &Value, key: &str) -> Result<f64> {
|
||||
match item.get(key) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_rot(item: &Value) -> Result<f64> {
|
||||
let n = album_finite(item, "rot")?;
|
||||
Ok(((n % 360.0) + 360.0) % 360.0)
|
||||
}
|
||||
|
||||
fn album_unit(item: &Value, key: &str) -> Result<f64> {
|
||||
let n = album_finite(item, key)?;
|
||||
if !(-1.0..=1.0).contains(&n) {
|
||||
return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1")));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn album_items(p: &Value) -> Result<Vec<AlbumItem>> {
|
||||
let Some(arr) = p.get("pictures").and_then(Value::as_array) else {
|
||||
return Err(bad("insert.photoAlbum", "`pictures` must be a list"));
|
||||
};
|
||||
if arr.is_empty() {
|
||||
return Err(bad("insert.photoAlbum", "add at least one picture or text box"));
|
||||
}
|
||||
if arr.len() > ALBUM_MAX_ITEMS {
|
||||
return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes"));
|
||||
}
|
||||
let mut items = Vec::with_capacity(arr.len());
|
||||
let mut total = 0usize;
|
||||
for item in arr {
|
||||
let Some(obj) = item.as_object() else {
|
||||
return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box"));
|
||||
};
|
||||
let has_media = obj.contains_key("data") || obj.contains_key("path");
|
||||
if !has_media {
|
||||
let Some(text) = obj.get("text").and_then(Value::as_str) else {
|
||||
return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`"));
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(bad("insert.photoAlbum", "a text box needs some text"));
|
||||
}
|
||||
if text.chars().count() > ALBUM_MAX_TEXT {
|
||||
return Err(bad("insert.photoAlbum", "text is longer than 400 characters"));
|
||||
}
|
||||
items.push(AlbumItem::Text(text.to_string()));
|
||||
continue;
|
||||
}
|
||||
let (name, bytes) = media_bytes(item, "insert.photoAlbum")?;
|
||||
if bytes.len() > ALBUM_MAX_BYTES {
|
||||
return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB")));
|
||||
}
|
||||
let next = total.saturating_add(bytes.len());
|
||||
if next > ALBUM_MAX_TOTAL {
|
||||
return Err(bad("insert.photoAlbum", "the album is larger than 64 MB"));
|
||||
}
|
||||
total = next;
|
||||
let ct = content_type(&name, &bytes);
|
||||
if !ct.starts_with("image/") {
|
||||
return Err(bad("insert.photoAlbum", format!("{name} is not a picture")));
|
||||
}
|
||||
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
|
||||
return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read")));
|
||||
}
|
||||
let (px_w, px_h) = image_px(&bytes);
|
||||
items.push(AlbumItem::Picture(AlbumPic {
|
||||
name,
|
||||
bytes,
|
||||
px_w,
|
||||
px_h,
|
||||
rot: album_rot(item)?,
|
||||
brightness: album_unit(item, "brightness")?,
|
||||
contrast: album_unit(item, "contrast")?,
|
||||
}));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.map(|(w, h)| (w as f64, h as f64))
|
||||
.filter(|(w, h)| *w > 0.0 && *h > 0.0)
|
||||
.unwrap_or((4.0, 3.0))
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
};
|
||||
if stem.is_empty() { "Picture".to_string() } else { stem.to_string() }
|
||||
}
|
||||
|
||||
fn album_plan(items: Vec<AlbumItem>, per: usize) -> Vec<AlbumSlide> {
|
||||
let per = per.max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut batch = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
AlbumItem::Text(text) => {
|
||||
if !batch.is_empty() {
|
||||
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
|
||||
}
|
||||
out.push(AlbumSlide::Text(text));
|
||||
}
|
||||
AlbumItem::Picture(pic) => {
|
||||
batch.push(pic);
|
||||
if batch.len() >= per {
|
||||
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
out.push(AlbumSlide::Pictures(batch));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Placed {
|
||||
pic: [f64; 4],
|
||||
caption: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) {
|
||||
let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 };
|
||||
let box_w = box_w.max(1.0);
|
||||
let box_h = box_h.max(1.0);
|
||||
if !aspect.is_finite() || aspect <= 0.0 {
|
||||
return (box_w, box_h);
|
||||
}
|
||||
if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) }
|
||||
}
|
||||
|
||||
fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
|
||||
let margin = if layout.fit { 18.0 } else { 36.0 };
|
||||
let title_band = if layout.title { 48.0 } else { 0.0 };
|
||||
let gap = 12.0;
|
||||
let cap_h = 22.0;
|
||||
let cap_gap = 4.0;
|
||||
let left = margin;
|
||||
let top = margin + title_band;
|
||||
let grid_w = (width - margin - left).max(1.0);
|
||||
let grid_h = (height - margin - top).max(1.0);
|
||||
let cols = layout.cols.max(1);
|
||||
let rows = layout.rows.max(1);
|
||||
let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0);
|
||||
let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0);
|
||||
let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h };
|
||||
let mut placed = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let col = i % cols;
|
||||
let row = i / cols;
|
||||
if row >= rows {
|
||||
break;
|
||||
}
|
||||
let cell_x = left + col as f64 * (cell_w + gap);
|
||||
let cell_y = top + row as f64 * (cell_h + gap);
|
||||
placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None });
|
||||
}
|
||||
let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None };
|
||||
(title, placed)
|
||||
}
|
||||
|
||||
fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
|
||||
let (title, slots) = place_album(width, height, layout, pics.len(), captions);
|
||||
let placed = pics
|
||||
.iter()
|
||||
.zip(slots)
|
||||
.map(|(pic, slot)| {
|
||||
let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h);
|
||||
let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0;
|
||||
let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0;
|
||||
let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None };
|
||||
Placed { pic: [x, y, dw, dh], caption }
|
||||
})
|
||||
.collect();
|
||||
(title, placed)
|
||||
}
|
||||
|
||||
fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> {
|
||||
let params = match frame {
|
||||
AlbumFrame::None => return Ok(()),
|
||||
AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}),
|
||||
AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}),
|
||||
AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}),
|
||||
};
|
||||
s.execute("shape.line", ¶ms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct AlbumMark {
|
||||
id: u32,
|
||||
layout: String,
|
||||
frame: String,
|
||||
captions: bool,
|
||||
grayscale: bool,
|
||||
theme: String,
|
||||
}
|
||||
|
||||
fn parse_album_mark(raw: &str) -> Option<AlbumMark> {
|
||||
let rest = raw.strip_prefix("deckcraft-album:")?;
|
||||
let (id_str, rest) = rest.split_once(';').unwrap_or((rest, ""));
|
||||
let id = id_str.parse::<u32>().ok().filter(|id| *id != 0)?;
|
||||
let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() };
|
||||
if !rest.is_empty() {
|
||||
for part in rest.split(';') {
|
||||
let Some((k, v)) = part.split_once('=') else { continue };
|
||||
match k {
|
||||
"layout" => mark.layout = v.to_string(),
|
||||
"frame" => mark.frame = v.to_string(),
|
||||
"captions" => mark.captions = v == "1",
|
||||
"gray" => mark.grayscale = v == "1",
|
||||
"theme" => mark.theme = v.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(mark)
|
||||
}
|
||||
|
||||
fn album_marker(id: u32, opts: &AlbumOpts) -> String {
|
||||
let theme = opts.theme.as_deref().unwrap_or("");
|
||||
format!(
|
||||
"deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}",
|
||||
opts.layout_key,
|
||||
opts.frame_key,
|
||||
if opts.captions { 1 } else { 0 },
|
||||
if opts.grayscale { 1 } else { 0 },
|
||||
)
|
||||
}
|
||||
|
||||
fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> {
|
||||
let marker = album_marker(album_id, opts);
|
||||
s.edit(|doc, sel| {
|
||||
let Some(slide) = doc.slides.get_mut(sel.slide) else {
|
||||
return Err(bad("insert.photoAlbum", "no slide to mark"));
|
||||
};
|
||||
let slide = std::sync::Arc::make_mut(slide);
|
||||
if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) {
|
||||
slide.raw_ext = Some(marker);
|
||||
}
|
||||
slide.name = "Photo Album".into();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> {
|
||||
let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) };
|
||||
let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default();
|
||||
if current.eq_ignore_ascii_case(name) {
|
||||
return Ok(());
|
||||
}
|
||||
s.execute("design.theme", &json!({"name": name}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn album_id_param(p: &Value) -> Option<u32> {
|
||||
match p.get("album") {
|
||||
Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0),
|
||||
Some(Value::String(s)) => s.trim().parse::<u32>().ok().filter(|v| *v != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec<u64> {
|
||||
doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect()
|
||||
}
|
||||
|
||||
fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec<u64> {
|
||||
if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut start = index;
|
||||
while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") {
|
||||
start -= 1;
|
||||
}
|
||||
let mut end = index;
|
||||
while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") {
|
||||
end += 1;
|
||||
}
|
||||
doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect()
|
||||
}
|
||||
|
||||
/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker).
|
||||
fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
|
||||
let st = s.doc()?;
|
||||
if let Some(id) = album_id_param(p) {
|
||||
let ids = slides_with_album(&st.doc, id);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
|
||||
}
|
||||
return Ok((id, ids));
|
||||
}
|
||||
let Some(slide) = st.doc.slides.get(st.selection.slide) else {
|
||||
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
|
||||
};
|
||||
if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) {
|
||||
let ids = slides_with_album(&st.doc, mark.id);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
|
||||
}
|
||||
return Ok((mark.id, ids));
|
||||
}
|
||||
if slide.name == "Photo Album" {
|
||||
let ids = album_name_run(&st.doc, st.selection.slide);
|
||||
if !ids.is_empty() {
|
||||
return Ok((0, ids));
|
||||
}
|
||||
}
|
||||
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
|
||||
}
|
||||
|
||||
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
|
||||
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
|
||||
fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) {
|
||||
let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1);
|
||||
let per = if max_pics >= 3 {
|
||||
"4"
|
||||
} else if max_pics == 2 {
|
||||
"2"
|
||||
} else {
|
||||
"1"
|
||||
};
|
||||
let title =
|
||||
slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0)));
|
||||
let layout = if title { format!("{per}title") } else { per.into() };
|
||||
let width = slides.iter().find_map(|s| {
|
||||
s.shapes.iter().find_map(|sh| match &sh.kind {
|
||||
ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let frame = match width {
|
||||
None => "none",
|
||||
Some(w) if (w - 6.0).abs() < 0.2 => "simple",
|
||||
Some(w) if (w - 2.25).abs() < 0.2 => "rounded",
|
||||
Some(w) if w < 0.2 => "none",
|
||||
Some(_) => "rect",
|
||||
};
|
||||
let captions = slides.iter().any(|s| {
|
||||
let bottoms: Vec<f64> =
|
||||
s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect();
|
||||
!bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b)))
|
||||
});
|
||||
let mut any = false;
|
||||
let mut gray = true;
|
||||
for s in slides {
|
||||
for sh in &s.shapes {
|
||||
if let ShapeKind::Picture { fill } = &sh.kind {
|
||||
any = true;
|
||||
gray &= fill.adjust.grayscale;
|
||||
}
|
||||
}
|
||||
}
|
||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||
}
|
||||
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||
let mut items = Vec::new();
|
||||
for slide in slides {
|
||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||
if pics.is_empty() {
|
||||
let text = slide
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|sh| sh.text_box)
|
||||
.filter_map(|sh| sh.text.as_ref().map(|t| t.text()))
|
||||
.find(|t| !t.trim().is_empty())
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
items.push(json!({"text": text.trim()}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sh in pics {
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let media = doc.media(fill.media);
|
||||
let name = if !sh.descr.is_empty() {
|
||||
sh.descr.clone()
|
||||
} else {
|
||||
media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into())
|
||||
};
|
||||
items.push(json!({
|
||||
"name": name,
|
||||
"data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(),
|
||||
"rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0),
|
||||
"brightness": fill.adjust.brightness,
|
||||
"contrast": fill.adjust.contrast,
|
||||
}));
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn album_seed(s: &Session) -> Result<Value> {
|
||||
let (id, ids) = album_target(s, &Value::Null)?;
|
||||
let st = s.doc()?;
|
||||
let slides: Vec<&deckcraft_model::Slide> =
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
|
||||
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
|
||||
let inferred = infer_album(&slides, &theme_now);
|
||||
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
|
||||
let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark {
|
||||
(
|
||||
if mark.layout.is_empty() { inferred.0 } else { mark.layout },
|
||||
if mark.frame.is_empty() { inferred.1 } else { mark.frame },
|
||||
mark.captions,
|
||||
mark.grayscale,
|
||||
if mark.theme.is_empty() { theme_now } else { mark.theme },
|
||||
)
|
||||
} else {
|
||||
inferred
|
||||
};
|
||||
Ok(json!({
|
||||
"album": if id == 0 { Value::Null } else { json!(id) },
|
||||
"layout": layout,
|
||||
"frame": frame,
|
||||
"captions": captions,
|
||||
"grayscale": grayscale,
|
||||
"theme": theme,
|
||||
"items": items_from_slides(&st.doc, &slides),
|
||||
}))
|
||||
}
|
||||
|
||||
fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Option<usize>, album_id: u32) -> Result<Value> {
|
||||
let (width, height, first) = {
|
||||
let st = s.doc()?;
|
||||
let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) });
|
||||
(st.doc.slide_size.width, st.doc.slide_size.height, first)
|
||||
};
|
||||
let layout = opts.layout;
|
||||
let frame = opts.frame;
|
||||
let captions = opts.captions;
|
||||
let plan = album_plan(items, layout.per);
|
||||
let mut slides = 0usize;
|
||||
let mut pictures = 0usize;
|
||||
let mut pin = at;
|
||||
for slide in plan {
|
||||
if let Some(index) = pin.take() {
|
||||
s.execute("slide.new", &json!({"layout": "blank", "at": index}))?;
|
||||
} else {
|
||||
s.execute("slide.new", &json!({"layout": "blank"}))?;
|
||||
}
|
||||
stamp_album_slide(s, album_id, opts)?;
|
||||
slides += 1;
|
||||
match slide {
|
||||
AlbumSlide::Text(text) => {
|
||||
let rect = if layout.title {
|
||||
[36.0, 18.0, (width - 72.0).max(36.0), 48.0]
|
||||
} else {
|
||||
[36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)]
|
||||
};
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?;
|
||||
}
|
||||
AlbumSlide::Pictures(pics) => {
|
||||
let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default();
|
||||
let (title, placed) = place_pictures(width, height, layout, &pics, captions);
|
||||
for (pic, cell) in pics.iter().zip(placed) {
|
||||
let data = base64_encode(&pic.bytes);
|
||||
let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?;
|
||||
let Some(id) = inserted.get("id").and_then(Value::as_u64) else {
|
||||
return Err(bad("insert.photoAlbum", "the picture was not inserted"));
|
||||
};
|
||||
apply_frame(s, frame, id)?;
|
||||
if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 {
|
||||
s.execute(
|
||||
"picture.adjust",
|
||||
&json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}),
|
||||
)?;
|
||||
}
|
||||
if pic.rot > 0.01 {
|
||||
s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?;
|
||||
}
|
||||
pictures += 1;
|
||||
if let Some(cap) = cell.caption {
|
||||
s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?;
|
||||
}
|
||||
}
|
||||
if let Some(rect) = title {
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use super::{
|
||||
Chart, ChartType, Result, RunKind, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, bool_param, color_value, edit_shapes, json,
|
||||
rect_param, str_param,
|
||||
};
|
||||
|
||||
pub fn chart_type(name: &str) -> ChartType {
|
||||
match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() {
|
||||
"bar" | "clusteredbar" => ChartType::Bar,
|
||||
"stackedbar" => ChartType::StackedBar,
|
||||
"percentbar" => ChartType::PercentBar,
|
||||
"line" => ChartType::Line,
|
||||
"linemarkers" | "linewithmarkers" => ChartType::LineMarkers,
|
||||
"stackedline" => ChartType::StackedLine,
|
||||
"area" => ChartType::Area,
|
||||
"stackedarea" => ChartType::StackedArea,
|
||||
"pie" => ChartType::Pie,
|
||||
"doughnut" | "donut" => ChartType::Doughnut,
|
||||
"scatter" | "xy" => ChartType::Scatter,
|
||||
"bubble" => ChartType::Bubble,
|
||||
"radar" => ChartType::Radar,
|
||||
"filledradar" => ChartType::FilledRadar,
|
||||
"stackedcolumn" => ChartType::StackedColumn,
|
||||
"percentcolumn" => ChartType::PercentColumn,
|
||||
"waterfall" => ChartType::Waterfall,
|
||||
"funnel" => ChartType::Funnel,
|
||||
"histogram" => ChartType::Histogram,
|
||||
"pareto" => ChartType::Pareto,
|
||||
"treemap" => ChartType::Treemap,
|
||||
"sunburst" => ChartType::Sunburst,
|
||||
"boxwhisker" | "box" => ChartType::BoxWhisker,
|
||||
"stock" => ChartType::Stock,
|
||||
"surface" => ChartType::Surface,
|
||||
"combo" => ChartType::Combo,
|
||||
_ => ChartType::Column,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn chart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = chart_type(str_param(p, "type").unwrap_or("column"));
|
||||
let mut c = Chart::sample(kind);
|
||||
if let Some(cats) = p.get("categories").and_then(Value::as_array) {
|
||||
c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect();
|
||||
}
|
||||
if let Some(series) = p.get("series").and_then(Value::as_array) {
|
||||
c.series = series
|
||||
.iter()
|
||||
.map(|sv| deckcraft_model::chart::Series {
|
||||
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
|
||||
values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(String::from);
|
||||
}
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7));
|
||||
let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _kinds() -> RunKind {
|
||||
RunKind::Text
|
||||
}
|
||||
|
||||
fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result<Value> {
|
||||
edit_shapes(s, p, cmd, |sh| match &mut sh.kind {
|
||||
ShapeKind::Chart(c) => f(c),
|
||||
_ => Err(bad(cmd, "not a chart")),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn chart_type_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = chart_type(str_param(p, "type").unwrap_or("column"));
|
||||
with_chart(s, p, "chart.type", |c| {
|
||||
c.kind = k;
|
||||
c.vary_colors =
|
||||
matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall)
|
||||
|| c.series.len() == 1 && c.vary_colors;
|
||||
if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
|
||||
c.gridlines = true;
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cats: Option<Vec<String>> = p
|
||||
.get("categories")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect());
|
||||
let series: Option<Vec<deckcraft_model::chart::Series>> = p.get("series").and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.map(|sv| deckcraft_model::chart::Series {
|
||||
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
|
||||
values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let levels: Option<Vec<Vec<String>>> = p.get("levels").and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.take(deckcraft_model::chart::MAX_LEVELS)
|
||||
.map(|l| {
|
||||
l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
with_chart(s, p, "chart.data", |c| {
|
||||
if let Some(cs) = &cats {
|
||||
c.categories = cs.clone();
|
||||
}
|
||||
if let Some(l) = &levels {
|
||||
c.category_levels = l.clone();
|
||||
}
|
||||
if let Some(se) = &series {
|
||||
// Keep series formatting by position.
|
||||
let old = std::mem::take(&mut c.series);
|
||||
c.series = se
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut x = old.get(i).cloned().unwrap_or_default();
|
||||
x.name = n.name.clone();
|
||||
x.values = n.values.clone();
|
||||
x
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0));
|
||||
if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() {
|
||||
return Err(bad("chart.options", "binSize must be a positive number"));
|
||||
}
|
||||
// Waterfall total points.
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
|
||||
// default (tx1 at lumMod 65 % / lumOff 35 %).
|
||||
let text_color = match p.get("textColor") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(v) => {
|
||||
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
|
||||
}
|
||||
};
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = &text_color {
|
||||
c.text_color = t.clone();
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(String::from);
|
||||
}
|
||||
if let Some(l) = p.get("legend") {
|
||||
c.legend = l.as_str().map(String::from);
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
// A series' own setting would otherwise keep overriding the chart-wide switch.
|
||||
for series in &mut c.series {
|
||||
series.data_labels = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "palette") {
|
||||
c.palette = v.to_string();
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::style::LineJoin;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 8×8 red PNG.
|
||||
fn png() -> Vec<u8> {
|
||||
vec![
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68,
|
||||
65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
|
||||
130,
|
||||
]
|
||||
}
|
||||
|
||||
fn pic(name: &str) -> Value {
|
||||
json!({"name": name, "data": base64_encode(&png())})
|
||||
}
|
||||
|
||||
fn n_slides(s: &Session) -> usize {
|
||||
s.active().map(|d| d.doc.slides.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn pictures(s: &Session, index: usize) -> Vec<deckcraft_model::Shape> {
|
||||
s.active()
|
||||
.and_then(|d| d.doc.slides.get(index))
|
||||
.map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn box_text(s: &Session, index: usize) -> Vec<String> {
|
||||
s.active()
|
||||
.and_then(|d| d.doc.slides.get(index))
|
||||
.map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pictures_one_per_slide_are_one_undo() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album");
|
||||
assert_eq!(r["slides"], 2);
|
||||
assert_eq!(n_slides(&s), before + 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
assert_eq!(s.active().unwrap().selection.slide, 1);
|
||||
let undo = &s.active().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), 1);
|
||||
assert_eq!(undo[0].label, "Photo Album…");
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(pictures(&s, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_up_shares_one_slide() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album");
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
assert!(box_text(&s, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captions_and_titles_use_the_file_stem() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions");
|
||||
assert_eq!(box_text(&s, 1), ["harbor"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title");
|
||||
assert_eq!(box_text(&s, 1), ["harbor"]);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y;
|
||||
assert!(pic_y > 40.0, "the picture sits below the title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_set_the_picture_line() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect");
|
||||
let line = pictures(&s, 1)[0].line.clone().expect("line");
|
||||
assert_eq!(line.width, Some(1.5));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded");
|
||||
let line = pictures(&s, 1)[0].line.clone().expect("line");
|
||||
assert_eq!(line.width, Some(2.25));
|
||||
assert_eq!(line.join, Some(LineJoin::Round));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple");
|
||||
assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_box_breaks_the_picture_row() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s
|
||||
.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"}))
|
||||
.expect("album");
|
||||
assert_eq!(r["slides"], 3);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(box_text(&s, 2), ["A day on the water"]);
|
||||
assert!(pictures(&s, 2).is_empty());
|
||||
assert_eq!(pictures(&s, 3).len(), 1);
|
||||
assert_eq!(s.active().unwrap().history.undo.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pictures_opens_the_dialog_without_editing() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let r = s.execute("insert.photoAlbum", &json!({})).expect("open");
|
||||
assert_eq!(r["dialog"], "photoAlbum");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(s.active().unwrap().history.undo.is_empty());
|
||||
assert_eq!(s.ui_requests.len(), 1);
|
||||
match &s.ui_requests[0] {
|
||||
crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"),
|
||||
other => panic!("dialog request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_adds_no_slides() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err());
|
||||
let mut many = Vec::new();
|
||||
for i in 0..49 {
|
||||
many.push(json!({"text": format!("box {i}")}));
|
||||
}
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err());
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(s.active().unwrap().history.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap();
|
||||
let one = pictures(&s, 1)[0].xfrm.unwrap().h;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap();
|
||||
let fitted = pictures(&s, 1)[0].xfrm.unwrap().h;
|
||||
assert!(fitted > one, "fit {fitted} should be taller than one-up {one}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 4);
|
||||
}
|
||||
|
||||
fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust {
|
||||
match &pictures(s, index)[0].kind {
|
||||
ShapeKind::Picture { fill } => fill.adjust.clone(),
|
||||
_ => panic!("picture"),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_name(s: &Session) -> String {
|
||||
s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_without_pictures_reopens_the_dialog() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
|
||||
s.ui_requests.clear();
|
||||
let before = n_slides(&s);
|
||||
let undos = s.active().unwrap().history.undo.len();
|
||||
let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen");
|
||||
assert_eq!(r["dialog"], "photoAlbum");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert_eq!(s.active().unwrap().history.undo.len(), undos);
|
||||
let params = match &s.ui_requests[0] {
|
||||
crate::UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "photoAlbum");
|
||||
params
|
||||
}
|
||||
other => panic!("dialog request, got {other:?}"),
|
||||
};
|
||||
assert_eq!(params["items"][0]["name"], "hill.png");
|
||||
assert_eq!(params["items"][1]["name"], "lake.png");
|
||||
assert_eq!(params["layout"], "1");
|
||||
assert!(params["album"].as_u64().unwrap_or(0) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_reflows_the_album_in_one_undo() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 3);
|
||||
let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow");
|
||||
assert_eq!(r["slides"], 1);
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
let undo = &s.active().unwrap().history.undo;
|
||||
assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(n_slides(&s), 3);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_options_and_theme_are_one_step() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute(
|
||||
"insert.photoAlbum",
|
||||
&json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}),
|
||||
)
|
||||
.unwrap();
|
||||
let adj = adjust(&s, 1);
|
||||
assert!(adj.grayscale);
|
||||
assert!((adj.brightness - 0.2).abs() < 1e-6);
|
||||
assert!((adj.contrast + 0.1).abs() < 1e-6);
|
||||
assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01);
|
||||
assert_eq!(theme_name(&s), "Harbor");
|
||||
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
|
||||
assert!(mark.contains("gray=1"), "{mark}");
|
||||
assert!(mark.contains("theme=Harbor"), "{mark}");
|
||||
assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(theme_name(&s), "DeckCraft");
|
||||
assert_eq!(n_slides(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_theme_and_a_plain_slide_change_nothing() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err();
|
||||
assert!(err.to_string().contains("unknown theme"), "{err}");
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err());
|
||||
let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("not part of a photo album"), "{err}");
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err());
|
||||
assert_eq!(n_slides(&s), before + 1);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(theme_name(&s), "DeckCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_albums_stay_independent() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
|
||||
let last = n_slides(&s) - 1;
|
||||
s.execute("slide.go", &json!({"index": last})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 5);
|
||||
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 4);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
assert_eq!(pictures(&s, 3).len(), 2);
|
||||
let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
|
||||
let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default();
|
||||
assert!(first.starts_with("deckcraft-album:"));
|
||||
assert!(second.starts_with("deckcraft-album:"));
|
||||
assert_ne!(first.split(';').next(), second.split(';').next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_run_without_a_marker_still_reflows() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
|
||||
{
|
||||
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
for sl in doc.slides.iter_mut().skip(1) {
|
||||
std::sync::Arc::make_mut(sl).raw_ext = None;
|
||||
}
|
||||
}
|
||||
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
|
||||
assert!(mark.starts_with("deckcraft-album:"), "{mark}");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::{
|
||||
Geom, Result, SchemeSlot, Session, Shape, ShapeStyle, Value, add_shape, bad, bool_or, bool_param, default_rect, edit_shapes, json, rect_param,
|
||||
str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
// `display`: what a caret with nothing to link inserts, the address or the slide's title.
|
||||
let (action, display) = if let Some(u) = str_param(p, "url") {
|
||||
(Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string())
|
||||
} else if let Some(i) = usize_param(p, "slide") {
|
||||
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
|
||||
let title = slide.title();
|
||||
(Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title })
|
||||
} else {
|
||||
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
|
||||
};
|
||||
let display = str_param(p, "text").map_or(display, str::to_string);
|
||||
let link = Hyperlink {
|
||||
action,
|
||||
tooltip: str_param(p, "tooltip").unwrap_or("").to_string(),
|
||||
highlight_click: bool_or(p, "highlightClick", false),
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
};
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return crate::cmd::text::link_text(s, link, &display);
|
||||
}
|
||||
edit_shapes(s, p, "insert.hyperlink", |sh| {
|
||||
sh.click = Some(link.clone());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does
|
||||
/// in a show.
|
||||
pub(super) fn shape_action(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
const CMD: &str = "shape.action";
|
||||
let hover = match str_param(p, "on").unwrap_or("click") {
|
||||
"click" => false,
|
||||
"hover" | "over" => true,
|
||||
o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))),
|
||||
};
|
||||
let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`")));
|
||||
let action = match str_param(p, "action").unwrap_or("none") {
|
||||
"none" => None,
|
||||
"next" => Some(Action::NextSlide),
|
||||
"previous" => Some(Action::PreviousSlide),
|
||||
"first" => Some(Action::FirstSlide),
|
||||
"last" => Some(Action::LastSlide),
|
||||
"lastViewed" => Some(Action::LastViewed),
|
||||
"endShow" => Some(Action::EndShow),
|
||||
"playMedia" => Some(Action::PlayMedia),
|
||||
"url" => Some(Action::Url { url: need("url")? }),
|
||||
"customShow" => {
|
||||
let name = need("show")?;
|
||||
if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) {
|
||||
return Err(bad(CMD, format!("no custom show `{name}`")));
|
||||
}
|
||||
Some(Action::CustomShow { name, returns: true })
|
||||
}
|
||||
"program" => Some(Action::Program { path: need("path")? }),
|
||||
"slide" => {
|
||||
let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?;
|
||||
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?;
|
||||
Some(Action::Slide { slide: slide.id })
|
||||
}
|
||||
a => return Err(bad(CMD, format!("unknown action `{a}`"))),
|
||||
};
|
||||
let highlight = bool_param(p, "highlight").unwrap_or(true);
|
||||
let tooltip = str_param(p, "tooltip").unwrap_or("").to_string();
|
||||
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None });
|
||||
edit_shapes(s, p, CMD, |sh| {
|
||||
if hover {
|
||||
sh.hover = link.clone();
|
||||
} else {
|
||||
sh.click = link.clone();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let kind = str_param(p, "kind").unwrap_or("custom");
|
||||
let (preset, action) = match kind {
|
||||
"back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)),
|
||||
"forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)),
|
||||
"beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)),
|
||||
"end" => ("actionButtonEnd", Some(Action::LastSlide)),
|
||||
"home" => ("actionButtonHome", Some(Action::FirstSlide)),
|
||||
"information" => ("actionButtonInformation", None),
|
||||
"return" => ("actionButtonReturn", Some(Action::LastViewed)),
|
||||
"movie" => ("actionButtonMovie", None),
|
||||
"document" => ("actionButtonDocument", None),
|
||||
"sound" => ("actionButtonSound", None),
|
||||
"help" => ("actionButtonHelp", None),
|
||||
_ => ("actionButtonBlank", None),
|
||||
};
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0));
|
||||
let shape = Shape {
|
||||
xfrm: Some(rect),
|
||||
geom: Geom::preset(preset),
|
||||
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
|
||||
click: action.map(|a| Hyperlink {
|
||||
action: a,
|
||||
tooltip: String::new(),
|
||||
highlight_click: true,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let id = add_shape(s, "insert.actionButton", shape, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::{
|
||||
EngineError, MediaClip, NO_ID_SPACE, PictureFill, Result, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, base64_decode, default_rect,
|
||||
json, rect_param, str_param, xfrm_of,
|
||||
};
|
||||
|
||||
/// Read media bytes from `data` (base64) or `path`.
|
||||
pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
|
||||
if let Some(d) = str_param(p, "data") {
|
||||
let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes));
|
||||
}
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into());
|
||||
return Ok((name, bytes));
|
||||
}
|
||||
Err(bad(cmd, "missing `path` or `data`"))
|
||||
}
|
||||
|
||||
pub fn content_type(name: &str, bytes: &[u8]) -> &'static str {
|
||||
let l = name.to_ascii_lowercase();
|
||||
if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
"image/jpeg"
|
||||
} else if bytes.starts_with(b"GIF8") {
|
||||
"image/gif"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
|
||||
"image/webp"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") {
|
||||
"audio/wav"
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
"image/bmp"
|
||||
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
|
||||
"image/tiff"
|
||||
} else if bytes.starts_with(b"fLaC") {
|
||||
"audio/flac"
|
||||
} else if bytes.starts_with(b"OggS") {
|
||||
if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" }
|
||||
} else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 {
|
||||
// ADTS (layer bits 00): AAC.
|
||||
"audio/aac"
|
||||
} else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) {
|
||||
"audio/mpeg"
|
||||
} else if bytes.starts_with(b"FORM") {
|
||||
"audio/aiff"
|
||||
} else if bytes.get(4..8) == Some(b"ftyp") {
|
||||
if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") {
|
||||
"audio/mp4"
|
||||
} else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") {
|
||||
"video/quicktime"
|
||||
} else {
|
||||
"video/mp4"
|
||||
}
|
||||
} else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
|
||||
if l.ends_with(".mka") || l.ends_with(".weba") {
|
||||
"audio/webm"
|
||||
} else if l.ends_with(".mkv") {
|
||||
"video/x-matroska"
|
||||
} else {
|
||||
"video/webm"
|
||||
}
|
||||
} else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) {
|
||||
if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" }
|
||||
} else if bytes.starts_with(b"caff") {
|
||||
"audio/x-caf"
|
||||
} else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b"<svg") {
|
||||
"image/svg+xml"
|
||||
} else if l.ends_with(".aac") {
|
||||
"audio/aac"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
/// Natural size of an image in points (96 dpi), fitted inside the slide.
|
||||
fn fitted(s: &Session, bytes: &[u8]) -> (f64, f64) {
|
||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75))
|
||||
.unwrap_or((288.0, 216.0));
|
||||
let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
|
||||
let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0);
|
||||
(w * k, h * k)
|
||||
}
|
||||
|
||||
pub(super) fn picture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = media_bytes(p, "insert.picture")?;
|
||||
let ct = content_type(&name, &bytes);
|
||||
if !ct.starts_with("image/") {
|
||||
return Err(bad("insert.picture", format!("{name} is not a picture")));
|
||||
}
|
||||
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
|
||||
return Err(EngineError::Other(format!("{name}: the picture can't be read")));
|
||||
}
|
||||
let (w, h) = fitted(s, &bytes);
|
||||
// Into a selected empty picture/content placeholder, if there is one.
|
||||
let target_ph = s.active().and_then(|d| {
|
||||
d.selected_shapes()
|
||||
.into_iter()
|
||||
.find(|x| {
|
||||
x.ph_type()
|
||||
.is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body))
|
||||
&& x.text.as_ref().is_none_or(|t| t.is_empty())
|
||||
})
|
||||
.map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x)))
|
||||
});
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph {
|
||||
Some((_, b)) => {
|
||||
// Fill the placeholder, cropping the picture to its aspect.
|
||||
b
|
||||
}
|
||||
None => default_rect(s, w, h),
|
||||
});
|
||||
let crop = match (target_ph, rect_param(p, "rect")) {
|
||||
(Some(_), None) => {
|
||||
let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9));
|
||||
if ia > ba {
|
||||
let c = (1.0 - ba / ia) / 2.0;
|
||||
[c, 0.0, c, 0.0]
|
||||
} else {
|
||||
let c = (1.0 - ia / ba) / 2.0;
|
||||
[0.0, c, 0.0, c]
|
||||
}
|
||||
}
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?;
|
||||
let shape = Shape {
|
||||
xfrm: Some(rect),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } },
|
||||
descr: name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some((ph_id, _)) = target_ph {
|
||||
// Replace the placeholder with the picture (it keeps the placeholder link).
|
||||
let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone());
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?;
|
||||
if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) {
|
||||
sh.kind = shape.kind.clone();
|
||||
sh.xfrm = Some(rect);
|
||||
sh.ph = phv.map(|mut p| {
|
||||
p.kind = deckcraft_model::PhType::Picture;
|
||||
p
|
||||
});
|
||||
sh.text = None;
|
||||
sh.descr = name.clone();
|
||||
}
|
||||
sel.shapes = vec![ph_id];
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"id": ph_id}));
|
||||
}
|
||||
let id = add_shape(s, "insert.picture", shape, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
|
||||
let cmd = if video { "insert.video" } else { "insert.audio" };
|
||||
let (name, bytes) = media_bytes(p, cmd)?;
|
||||
let ct = content_type(&name, &bytes);
|
||||
let bytes = std::sync::Arc::new(bytes);
|
||||
let info = deckcraft_media::probe(&bytes);
|
||||
let has_video = info.as_ref().is_ok_and(|i| i.video.is_some());
|
||||
let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some());
|
||||
match &info {
|
||||
Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))),
|
||||
Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))),
|
||||
// Not something we can read: embed it anyway when the type says it's media (it still
|
||||
// round-trips through PPTX), otherwise refuse.
|
||||
Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))),
|
||||
Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))),
|
||||
_ => {}
|
||||
}
|
||||
let (duration_ms, width, height, playable) = match &info {
|
||||
Ok(i) => {
|
||||
let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0));
|
||||
(i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable())
|
||||
}
|
||||
Err(_) => (0, 0, 0, false),
|
||||
};
|
||||
let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable));
|
||||
let warning = if !playable {
|
||||
Some(match &info {
|
||||
Ok(i) => {
|
||||
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
|
||||
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
|
||||
}
|
||||
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
|
||||
})
|
||||
} else if video && !decodable_video {
|
||||
// A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame
|
||||
// can ever show — say so instead of leaving an unexplained black box (#1237).
|
||||
info.as_ref()
|
||||
.ok()
|
||||
.and_then(|i| i.video.as_ref())
|
||||
.map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let poster_png = if video && decodable_video { crate::cmd::media::poster_png(&bytes, 0) } else { None };
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| {
|
||||
if video {
|
||||
// Natural size (96 dpi), fitted inside 90 % of the slide and centred.
|
||||
let (w, h) =
|
||||
if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) };
|
||||
let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0);
|
||||
let (w, h) = (w * k, h * k);
|
||||
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
|
||||
} else {
|
||||
Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0)
|
||||
}
|
||||
});
|
||||
let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone());
|
||||
let (media, poster) = s.edit(|doc, _| {
|
||||
let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?;
|
||||
let poster = match poster_png {
|
||||
Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?),
|
||||
None => None,
|
||||
};
|
||||
Ok((media, poster))
|
||||
})?;
|
||||
let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() };
|
||||
let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?;
|
||||
if let Some(w) = &warning {
|
||||
s.ui_requests.push(crate::UiRequest::Message { text: w.clone() });
|
||||
}
|
||||
let mut r =
|
||||
json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning});
|
||||
if let Ok(i) = &info {
|
||||
r["probe"] = crate::cmd::media::probe_json(i);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub(super) fn audio(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
media(s, p, false)
|
||||
}
|
||||
|
||||
pub(super) fn video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
media(s, p, true)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use super::{
|
||||
ColorRef, Fill, Result, RunKind, SchemeSlot, Session, TextBody, Value, Xfrm, add_shape, bad, bool_or, bool_param, json, new_text_box, ok,
|
||||
str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn word_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").unwrap_or("Your text here");
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let style = usize_param(p, "style").unwrap_or(0);
|
||||
let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text);
|
||||
if let Some(t) = shape.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.align = Some(deckcraft_model::text::Align::Center);
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(54.0);
|
||||
r.props.bold = Some(style % 2 == 1);
|
||||
r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 {
|
||||
0 => SchemeSlot::Tx1,
|
||||
1 => SchemeSlot::Accent1,
|
||||
2 => SchemeSlot::Accent2,
|
||||
_ => SchemeSlot::Accent4,
|
||||
})));
|
||||
if style % 3 == 2 {
|
||||
r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let id = add_shape(s, "insert.wordArt", shape, true)?;
|
||||
crate::cmd::text::fit_text_box(s, id)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn field_run(s: &mut Session, field: &str, text: &str) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return crate::cmd::text::insert_field(s, field, text);
|
||||
}
|
||||
// Not editing: turn on the footer field for the slide (Header & Footer).
|
||||
let key = if field == "slidenum" { "slideNumber" } else { "date" };
|
||||
s.execute("design.headerFooter", &json!({key: true}))
|
||||
}
|
||||
|
||||
pub(super) fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
field_run(s, "slidenum", "‹#›")
|
||||
}
|
||||
|
||||
const DATE_FORMATS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
struct DateOpts {
|
||||
format: String,
|
||||
update: bool,
|
||||
/// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language.
|
||||
lang: Option<String>,
|
||||
sample: String,
|
||||
}
|
||||
|
||||
/// `true` when the call carries a format, update flag or language (apply it). Empty params open
|
||||
/// the dialog instead.
|
||||
fn date_time_apply(p: &Value) -> bool {
|
||||
p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language"))
|
||||
}
|
||||
|
||||
fn canon_lang(raw: &str) -> Option<&'static str> {
|
||||
match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"en" | "en-us" => Some("en-US"),
|
||||
"en-gb" => Some("en-GB"),
|
||||
"fr" | "fr-fr" => Some("fr-FR"),
|
||||
"ja" | "ja-jp" => Some("ja-JP"),
|
||||
"sv" | "sv-se" => Some("sv-SE"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn date_opts(p: &Value) -> Result<DateOpts> {
|
||||
if p.get("format").is_some() {
|
||||
let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f));
|
||||
if f.is_none() {
|
||||
return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13"));
|
||||
}
|
||||
}
|
||||
if p.get("update").is_some() && bool_param(p, "update").is_none() {
|
||||
return Err(bad("insert.dateTime", "`update` must be true or false"));
|
||||
}
|
||||
let lang = if p.get("language").is_some() {
|
||||
let raw = str_param(p, "language").filter(|l| !l.trim().is_empty());
|
||||
let Some(raw) = raw else {
|
||||
return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"));
|
||||
};
|
||||
Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let format = str_param(p, "format").unwrap_or("datetime1").to_string();
|
||||
let sample = date_sample(&format, lang.as_deref())?;
|
||||
Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample })
|
||||
}
|
||||
|
||||
/// The text `format` shows now in `lang`. Errors when that language has no names for the format
|
||||
/// (month names and AM/PM are English only). An empty string where there is no clock (wasm).
|
||||
fn date_sample(format: &str, lang: Option<&str>) -> Result<String> {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
let probe = DateTime::new(2026, 10, 9, 20, 5, 7);
|
||||
if field_text(format, lang, probe).is_none() {
|
||||
return Err(bad("insert.dateTime", "this format isn't available in that language"));
|
||||
}
|
||||
Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !date_time_apply(p) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) });
|
||||
return ok();
|
||||
}
|
||||
let opts = date_opts(p)?;
|
||||
let (doc, sel) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.selection.clone())
|
||||
};
|
||||
let r = apply_date(s, &opts);
|
||||
if r.is_err()
|
||||
&& let Some(st) = s.active_mut()
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = sel;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
if o.update {
|
||||
crate::cmd::text::insert_field(s, &o.format, &o.sample)?;
|
||||
if let Some(lang) = &o.lang {
|
||||
stamp_caret_lang(s, lang)?;
|
||||
}
|
||||
} else {
|
||||
insert_plain(s, &o.sample, o.lang.as_deref())?;
|
||||
}
|
||||
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
|
||||
}
|
||||
// Handout or Notes Master view: the master's date placeholder (the selected one, else the
|
||||
// master's own), which every handout or notes page then shows.
|
||||
if s.doc()?.selection.target.is_page_master() {
|
||||
return write_master_date(s, o);
|
||||
}
|
||||
// Not editing: the slide's date placeholder (this slide only). A layout with no date
|
||||
// placeholder gets a text box instead, so the chosen format is still inserted.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
|
||||
if !write_date_placeholder(s, o)? {
|
||||
return insert_date_box(s, o);
|
||||
}
|
||||
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Put the date in the date placeholder of the handout or notes master being edited.
|
||||
fn write_master_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let (format, sample, lang, update) = (o.format.clone(), o.sample.clone(), o.lang.clone(), o.update);
|
||||
let has_date = {
|
||||
let st = s.doc()?;
|
||||
crate::page_master(&st.doc, st.selection.target).is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)))
|
||||
};
|
||||
if !has_date {
|
||||
return Err(bad(
|
||||
"insert.dateTime",
|
||||
"this master has no date placeholder; show it with handout.placeholders or notes.placeholders {date: true}",
|
||||
));
|
||||
}
|
||||
// The text the master holds afterwards, which the answer reports (it can differ from the
|
||||
// caller's sample when the placeholder's own language formats a fixed date).
|
||||
let mut written = o.sample.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let Some(m) = crate::page_master_mut(doc, sel.target) else { return Ok(false) };
|
||||
let m = std::sync::Arc::make_mut(m);
|
||||
let date = |x: &deckcraft_model::Shape| x.ph_type() == Some(deckcraft_model::PhType::Date);
|
||||
let chosen = sel.shapes.iter().find_map(|id| m.shapes.iter().position(|x| x.id == *id && date(x)));
|
||||
let Some(i) = chosen.or_else(|| m.shapes.iter().position(date)) else { return Ok(false) };
|
||||
let Some(sh) = m.shapes.get_mut(i) else { return Ok(false) };
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
// A fixed date reads in the placeholder's own language when the caller named none.
|
||||
let text = match (&lang, &props.lang) {
|
||||
(None, Some(l)) if !update => date_sample(&format, Some(l)).unwrap_or_else(|_| sample.clone()),
|
||||
_ => sample.clone(),
|
||||
};
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
let mut para = body.paragraphs.first().cloned().unwrap_or_default();
|
||||
written.clone_from(&text);
|
||||
para.runs = vec![deckcraft_model::Run { text, props, kind }];
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})?;
|
||||
Ok(json!({"where": "master", "format": o.format, "update": o.update, "text": written}))
|
||||
}
|
||||
|
||||
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
|
||||
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
|
||||
let format = o.format.clone();
|
||||
let sample = o.sample.clone();
|
||||
let lang = o.lang.clone();
|
||||
let update = o.update;
|
||||
s.edit(|doc, sel| {
|
||||
let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) };
|
||||
let sl = std::sync::Arc::make_mut(sl);
|
||||
let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let y = (size.height - 48.0).max(0.0);
|
||||
s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?;
|
||||
if o.update {
|
||||
crate::cmd::text::insert_field(s, &o.format, &o.sample)?;
|
||||
} else {
|
||||
crate::cmd::text::insert_text(s, &o.sample, None)?;
|
||||
}
|
||||
if let Some(lang) = &o.lang {
|
||||
stamp_caret_lang(s, lang)?;
|
||||
}
|
||||
Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge,
|
||||
/// so the date stays its own run next to the surrounding text.
|
||||
fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> {
|
||||
let text = text.to_string();
|
||||
let lang = lang.map(str::to_string);
|
||||
crate::cmd::text::edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = deckcraft_model::edit::delete(body, a, b);
|
||||
let mut props = deckcraft_model::edit::props_at(body, at);
|
||||
if let Some(lang) = lang.clone() {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let c = deckcraft_model::edit::insert(body, at, &text, Some(props));
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates.
|
||||
fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> {
|
||||
let lang = lang.to_string();
|
||||
crate::cmd::text::edit_text(s, move |body, t| {
|
||||
let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return };
|
||||
let mut at = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let n = r.char_len();
|
||||
if n > 0 && at < t.caret.1 && t.caret.1 <= at + n {
|
||||
r.props.lang = Some(lang);
|
||||
return;
|
||||
}
|
||||
at += n;
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string();
|
||||
if s.doc()?.selection.text.is_none() {
|
||||
return Err(bad("insert.symbol", "place the insertion point in text first"));
|
||||
}
|
||||
crate::cmd::text::insert_text(s, &t, None)
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::Hyperlink;
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, opt_usize_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn actions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "index", "slide.actions")?.unwrap_or(st.selection.slide);
|
||||
let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?;
|
||||
let mut found = Vec::new();
|
||||
for sh in &sl.shapes {
|
||||
collect_actions(sh, &mut Vec::new(), &mut found);
|
||||
}
|
||||
Ok(json!({"index": i, "slideId": sl.id, "actions": found}))
|
||||
}
|
||||
|
||||
fn collect_actions(sh: &Shape, path: &mut Vec<Value>, found: &mut Vec<Value>) {
|
||||
path.push(json!({"id": sh.id, "name": sh.name}));
|
||||
let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path});
|
||||
if let Some(link) = &sh.click {
|
||||
found.push(action_entry(owner.clone(), "click", link));
|
||||
}
|
||||
if let Some(link) = &sh.hover {
|
||||
found.push(action_entry(owner.clone(), "hover", link));
|
||||
}
|
||||
if let Some(text) = &sh.text {
|
||||
collect_text_actions(text, &owner, found);
|
||||
}
|
||||
if let ShapeKind::Table(table) = &sh.kind {
|
||||
for (row_index, row) in table.rows.iter().enumerate() {
|
||||
for (cell_index, cell) in row.cells.iter().enumerate() {
|
||||
let mut cell_owner = owner.clone();
|
||||
cell_owner["tableRowIndex"] = json!(row_index);
|
||||
cell_owner["tableCellIndex"] = json!(cell_index);
|
||||
collect_text_actions(&cell.text, &cell_owner, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind {
|
||||
for child in children {
|
||||
collect_actions(child, path, found);
|
||||
}
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
|
||||
fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec<Value>) {
|
||||
for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() {
|
||||
for (run_index, run) in paragraph.runs.iter().enumerate() {
|
||||
for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] {
|
||||
if let Some(link) = link {
|
||||
let mut text_owner = owner.clone();
|
||||
text_owner["paragraphIndex"] = json!(paragraph_index);
|
||||
text_owner["runIndex"] = json!(run_index);
|
||||
text_owner["text"] = json!(run.content());
|
||||
found.push(action_entry(text_owner, event, link));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value {
|
||||
let action = serde_json::to_value(&link.action).unwrap_or_default();
|
||||
let kind = action.get("kind").cloned().unwrap_or(Value::Null);
|
||||
let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null);
|
||||
json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link})
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id =
|
||||
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::table::Row;
|
||||
use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody};
|
||||
use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table};
|
||||
|
||||
fn link(action: Action) -> Hyperlink {
|
||||
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_lists_group_shape_and_text_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() };
|
||||
child.click = Some(link(Action::NextSlide));
|
||||
child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() }));
|
||||
let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() };
|
||||
let mut paragraph = Paragraph::new("linked");
|
||||
paragraph.runs = vec![Run::with("linked", run_props)];
|
||||
child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() });
|
||||
let group = Shape {
|
||||
id: ShapeId(11),
|
||||
name: "Group".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide_index = st.selection.slide;
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group];
|
||||
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let actions = v["actions"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 3, "{v}");
|
||||
assert_eq!(actions[0]["event"], "click");
|
||||
assert_eq!(actions[0]["type"], "nextSlide");
|
||||
assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group");
|
||||
assert_eq!(actions[0]["owner"]["path"][1]["id"], 12);
|
||||
assert_eq!(actions[1]["event"], "hover");
|
||||
assert_eq!(actions[1]["target"], "https://example.com/hover");
|
||||
assert_eq!(actions[2]["event"], "textLink");
|
||||
assert_eq!(actions[2]["owner"]["runIndex"], 0);
|
||||
assert_eq!(actions[2]["target"], 7);
|
||||
|
||||
let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() };
|
||||
let mut cell_text = TextBody::from_text("cell link");
|
||||
cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)];
|
||||
let table =
|
||||
Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() };
|
||||
let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let st = s.doc_mut().unwrap();
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape);
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap();
|
||||
assert_eq!(table_action["event"], "textHover");
|
||||
assert_eq!(table_action["owner"]["tableRowIndex"], 0);
|
||||
assert_eq!(table_action["owner"]["tableCellIndex"], 0);
|
||||
assert_eq!(table_action["target"], "https://example.com/cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_returns_empty_array_for_slide_without_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
assert_eq!(v["actions"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_refuses_a_bad_index_instead_of_using_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
for index in [json!(-1), json!("0"), json!(1.5)] {
|
||||
assert!(s.execute("slide.actions", &json!({"index": index})).is_err(), "{index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, cmd, edit_shapes, has_doc, targets};
|
||||
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -179,7 +179,11 @@ fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::ShapeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::base64_encode;
|
||||
use crate::{MediaStatus, Session, UiRequest};
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let rate = 8000u32;
|
||||
@@ -286,4 +290,16 @@ mod tests {
|
||||
assert_eq!(r["playable"], false);
|
||||
assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() {
|
||||
let mut s = Session::with_new();
|
||||
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
|
||||
let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm");
|
||||
let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert");
|
||||
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
|
||||
let w = r["warning"].as_str().expect("the VP8 track can't be shown");
|
||||
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
|
||||
assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
|
||||
use i_overlay::float::single::SingleFloatOverlay;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_two, str_param, targets, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Closed polygons (slide points) describing a filled area (non-zero winding).
|
||||
@@ -255,7 +255,12 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Area, area_of, area_size, letters_of, merge_areas};
|
||||
use crate::Session;
|
||||
use crate::cmd::base64_encode;
|
||||
|
||||
fn square(x: f64, y: f64, s: f64) -> Area {
|
||||
vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]]
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, cmd, edit_shapes, has_selection, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
|
||||
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, cmd, f64_param, has_selection, id_param, ok, str_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -658,6 +658,62 @@ pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh
|
||||
None
|
||||
}
|
||||
|
||||
/// The point at parameter `t` of segment `si`, scaled by `scale` into the shape's box.
|
||||
fn segment_point(segs: &[Seg], si: usize, p0: Point, t: f64, scale: (f64, f64)) -> Option<Point> {
|
||||
let q = match segs.get(si)? {
|
||||
Seg::L(p1) => p0.lerp(*p1, t),
|
||||
Seg::Q(c, p1) => p0.lerp(*c, t).lerp(c.lerp(*p1, t), t),
|
||||
Seg::C(c1, c2, p1) => {
|
||||
let (a, b, c) = (p0.lerp(*c1, t), c1.lerp(*c2, t), c2.lerp(*p1, t));
|
||||
a.lerp(b, t).lerp(b.lerp(c, t), t)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Point::new(q.x * scale.0, q.y * scale.1))
|
||||
}
|
||||
|
||||
/// The vertex that ends segment `si` (a closing edge ends on the start).
|
||||
fn segment_end_vertex(segs: &[Seg], vs: &[usize], si: usize) -> Option<usize> {
|
||||
if closes_onto_start(segs, si) {
|
||||
let start = segs.get(..si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_))));
|
||||
start.and_then(|m| vs.iter().position(|v| *v == m))
|
||||
} else {
|
||||
vs.iter().position(|v| *v == si)
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance and `t` of the point on segment `si` nearest `local`, if it lies within `tol`.
|
||||
fn nearest_on_segment(segs: &[Seg], si: usize, p0: Point, scale: (f64, f64), local: Point, tol: f64) -> Option<(f64, f64)> {
|
||||
let at = |t: f64| segment_point(segs, si, p0, t, scale);
|
||||
let mut near: Option<(f64, f64)> = None;
|
||||
for k in 1..32 {
|
||||
let t = k as f64 / 32.0;
|
||||
if let Some(q) = at(t) {
|
||||
let d = (q - local).hypot();
|
||||
if d <= tol && near.is_none_or(|n| d < n.0) {
|
||||
near = Some((d, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut d, mut t) = near?;
|
||||
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
|
||||
let mut step = 1.0 / 32.0;
|
||||
for _ in 0..4 {
|
||||
step /= 8.0;
|
||||
let c = t;
|
||||
for k in -8..=8 {
|
||||
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
|
||||
if let Some(q) = at(u) {
|
||||
let e = (q - local).hypot();
|
||||
if e < d {
|
||||
(d, t) = (e, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((d, t))
|
||||
}
|
||||
|
||||
/// The segment of `sh`'s outline near slide point `p`: (path, index of the vertex ending it, t).
|
||||
pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize, f64)> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
@@ -665,57 +721,13 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s
|
||||
let local = x.affine().inverse() * p;
|
||||
let mut best: Option<(f64, usize, usize, f64)> = None;
|
||||
for (pi, cp) in paths.iter().enumerate() {
|
||||
let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 });
|
||||
let scale = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 });
|
||||
let segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
for si in 0..segs.len() {
|
||||
let Some(p0) = seg_start(&segs, si) else { continue };
|
||||
let at = |t: f64| -> Option<Point> {
|
||||
let q = match segs.get(si)? {
|
||||
Seg::L(p1) => p0.lerp(*p1, t),
|
||||
Seg::Q(c, p1) => p0.lerp(*c, t).lerp(c.lerp(*p1, t), t),
|
||||
Seg::C(c1, c2, p1) => {
|
||||
let (a, b, c) = (p0.lerp(*c1, t), c1.lerp(*c2, t), c2.lerp(*p1, t));
|
||||
a.lerp(b, t).lerp(b.lerp(c, t), t)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Point::new(q.x * sx, q.y * sy))
|
||||
};
|
||||
// The vertex that ends this segment (a closing edge ends on the start).
|
||||
let end_vertex = if closes_onto_start(&segs, si) {
|
||||
let start = segs.get(..si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_))));
|
||||
start.and_then(|m| vs.iter().position(|v| *v == m))
|
||||
} else {
|
||||
vs.iter().position(|v| *v == si)
|
||||
};
|
||||
let Some(ev) = end_vertex else { continue };
|
||||
let mut near: Option<(f64, f64)> = None;
|
||||
for k in 1..32 {
|
||||
let t = k as f64 / 32.0;
|
||||
if let Some(q) = at(t) {
|
||||
let d = (q - local).hypot();
|
||||
if d <= tol && near.is_none_or(|n| d < n.0) {
|
||||
near = Some((d, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((mut d, mut t)) = near else { continue };
|
||||
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
|
||||
let mut step = 1.0 / 32.0;
|
||||
for _ in 0..4 {
|
||||
step /= 8.0;
|
||||
let c = t;
|
||||
for k in -8..=8 {
|
||||
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
|
||||
if let Some(q) = at(u) {
|
||||
let e = (q - local).hypot();
|
||||
if e < d {
|
||||
(d, t) = (e, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(ev) = segment_end_vertex(&segs, &vs, si) else { continue };
|
||||
let Some((d, t)) = nearest_on_segment(&segs, si, p0, scale, local, tol) else { continue };
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::style::{PictureAdjust, PictureMode};
|
||||
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, current_shapes, edit_shapes, has_selection, str_param, targets, text, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+129
-14
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
use deckcraft_model::Comment;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, f64_or, has_doc, has_slide, has_text, id_param, ok, str_param, usize_param,
|
||||
};
|
||||
use crate::{Result, Session, Target, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -56,9 +58,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
insert_synonym
|
||||
),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare the open deck with another file → {name, changes: [{kind, slide, number, onlyIn?, revised, details}]}; each change is described from the open deck: `onlyIn: \"deck\"` is in the deck and not in the file, `onlyIn: \"file\"` is in the file and not in the deck, `changed` is in both and differs; `slide` is 0-based, `number` the editor's slide number; review.accept takes the FILE's side, so it deletes what is only in the deck (Accept All keeps the deck's last remaining slide rather than deleting it)", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide (0-based), number (editor's), onlyIn?: deck|file, revised, details}]}; `onlyIn: deck` = only in the open deck, `file` = only in the compared file; accepting takes the file's side", has_doc, revisions),
|
||||
cmd!(
|
||||
"review.accept",
|
||||
"Accept Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} — accept the compared file's side of a change (or all): what is only in the file is added, what is only in the open deck is deleted (all: true leaves the deck's last remaining slide in place), a changed slide becomes the file's",
|
||||
comparing,
|
||||
accept
|
||||
),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
@@ -262,19 +272,31 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(bg) = bg {
|
||||
for sh in &sl.shapes {
|
||||
let Some(t) = &sh.text else { continue };
|
||||
let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue };
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default());
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
let Some(t) = &sh.text else { return };
|
||||
let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh);
|
||||
if fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let fg = deckcraft_model::resolve::text_color(&ctx, &rp);
|
||||
if fg.contrast(bg) < 3.0 {
|
||||
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))}));
|
||||
let low = t
|
||||
.paragraphs
|
||||
.iter()
|
||||
.flat_map(|para| {
|
||||
// A line break or blank run shows no glyphs, so its colour can't be hard to read.
|
||||
para.runs.iter().filter(|run| run.kind != deckcraft_model::text::RunKind::Break && !run.text.trim().is_empty()).map(
|
||||
|run| {
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &run.props);
|
||||
deckcraft_model::resolve::text_color(&ctx, &rp).contrast(bg)
|
||||
},
|
||||
)
|
||||
})
|
||||
.find(|contrast| *contrast < 3.0);
|
||||
if let Some(contrast) = low {
|
||||
out.push(
|
||||
json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({contrast:.1}:1)")}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,7 +452,7 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
|
||||
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
|
||||
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
|
||||
let end = start + word.chars().count();
|
||||
let end = start.checked_add(word.chars().count()).ok_or_else(|| bad("review.spellingChange", "bad `start`"))?;
|
||||
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
|
||||
let slide = {
|
||||
let st = s.doc()?;
|
||||
@@ -843,3 +865,96 @@ mod tests {
|
||||
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spelling_change_range_tests {
|
||||
use crate::{EngineError, Session};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn spelling_change_huge_start_is_bad_params() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
|
||||
let id = r["id"].clone();
|
||||
let err = s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": u64::MAX, "word": "Hello", "to": "x"})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod accessibility_contrast_tests {
|
||||
use super::*;
|
||||
use deckcraft_model::text::{Paragraph, Run, RunKind, RunProps};
|
||||
use deckcraft_model::{ColorRef, Fill, Rgba, Shape, ShapeId, ShapeKind, TextBody};
|
||||
use serde_json::json;
|
||||
|
||||
fn run(text: &str, color: Rgba) -> Run {
|
||||
Run::with(text, RunProps { fill: Some(Fill::solid(ColorRef::rgb(color))), ..Default::default() })
|
||||
}
|
||||
|
||||
fn text_shape(id: u32, name: &str, paragraphs: Vec<Paragraph>) -> Shape {
|
||||
Shape { id: ShapeId(id), name: name.into(), text: Some(TextBody { paragraphs, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_checks_mixed_runs_later_paragraphs_and_group_descendants_once() {
|
||||
let mut s = Session::with_new();
|
||||
s.edit(|doc, _| {
|
||||
let black = Rgba::BLACK;
|
||||
let white = Rgba::WHITE;
|
||||
let mixed = Paragraph { runs: vec![run("visible", black), run("hidden", white)], ..Default::default() };
|
||||
let later = vec![
|
||||
Paragraph { runs: vec![run("visible", black)], ..Default::default() },
|
||||
Paragraph { runs: vec![run("hidden", white)], ..Default::default() },
|
||||
];
|
||||
let child = text_shape(5, "nested text", vec![Paragraph { runs: vec![run("hidden", white)], ..Default::default() }]);
|
||||
let group = Shape {
|
||||
id: ShapeId(4),
|
||||
name: "group".into(),
|
||||
descr: "group of text".into(),
|
||||
kind: ShapeKind::Group {
|
||||
children: vec![Shape {
|
||||
id: ShapeId(7),
|
||||
name: "nested group".into(),
|
||||
descr: "nested group of text".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
}],
|
||||
child: Default::default(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(0).ok_or_else(|| bad("test", "no slide"))?);
|
||||
sl.shapes = vec![
|
||||
text_shape(2, "mixed", vec![mixed]),
|
||||
text_shape(3, "later paragraph", later),
|
||||
group,
|
||||
text_shape(6, "black control", vec![Paragraph { runs: vec![run("visible", black)], ..Default::default() }]),
|
||||
text_shape(
|
||||
8,
|
||||
"invisible white runs",
|
||||
vec![Paragraph {
|
||||
runs: vec![run("visible", black), Run { kind: RunKind::Break, ..run("\n", white) }, run(" ", white), run("visible", black)],
|
||||
..Default::default()
|
||||
}],
|
||||
),
|
||||
];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let issues = s.execute("review.accessibility", &json!({})).unwrap();
|
||||
let contrasts: Vec<_> = issues
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|issue| issue["issue"].as_str().unwrap_or("").starts_with("Hard-to-read text contrast"))
|
||||
.collect();
|
||||
assert_eq!(contrasts.len(), 3, "{issues}");
|
||||
for id in [2, 3, 5] {
|
||||
assert!(contrasts.iter().any(|issue| issue["shape"] == id), "missing shape {id}: {issues}");
|
||||
}
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 6), "black control warned: {issues}");
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 8), "white break or spaces warned: {issues}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
@@ -14,20 +16,51 @@ const MAX_RUNS: usize = 10_000;
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
out.push_str(&open);
|
||||
if !push_if_fits(&mut out, &open, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
deckcraft_model::text::RunKind::Break => {
|
||||
if !push_if_fits(&mut out, "<br>", close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !push_escaped_bounded(&mut out, &r.text, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&close);
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text without formatting, or "" (no HTML on the clipboard) when even that is too big: a
|
||||
/// cut-off prefix would paste as if it were the whole text.
|
||||
fn plain_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return String::new();
|
||||
}
|
||||
for r in &p.runs {
|
||||
let fits = if matches!(&r.kind, deckcraft_model::text::RunKind::Break) {
|
||||
push_if_fits(&mut out, "<br>", 4)
|
||||
} else {
|
||||
push_escaped_bounded(&mut out, &r.text, 4)
|
||||
};
|
||||
if !fits {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
@@ -35,6 +68,43 @@ pub fn body_to_html(body: &TextBody) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn push_if_fits(out: &mut String, value: &str, reserve: usize) -> bool {
|
||||
if out.len().checked_add(value.len()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push_str(value);
|
||||
true
|
||||
}
|
||||
|
||||
fn push_escaped_bounded(out: &mut String, text: &str, reserve: usize) -> bool {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => {
|
||||
if !push_if_fits(out, "&", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if !push_if_fits(out, "<", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'>' => {
|
||||
if !push_if_fits(out, ">", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if out.len().checked_add(c.len_utf8()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
@@ -53,17 +123,6 @@ fn tags(props: &RunProps) -> (String, String) {
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
@@ -77,7 +136,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut stack: Vec<(Cow<'static, str>, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
@@ -106,23 +165,27 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => name.as_str(),
|
||||
kind => kind,
|
||||
};
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0.as_ref() == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (db, di, du) = (stack[pos].1, stack[pos].2, stack[pos].3);
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
@@ -133,7 +196,11 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => Cow::Owned(name.clone()),
|
||||
kind => Cow::Borrowed(kind),
|
||||
};
|
||||
stack.push((kind, db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
@@ -231,7 +298,11 @@ fn weight_bold(style: &str) -> Option<bool> {
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
let at = lower.find(key)?;
|
||||
let at = lower.match_indices(key).find_map(|(at, _)| {
|
||||
let named = lower[..at].ends_with(|c: char| c.is_ascii_whitespace());
|
||||
let eq = tag.get(at + key.len()..)?.trim_start().starts_with('=');
|
||||
(named && eq).then_some(at)
|
||||
})?;
|
||||
let after = tag.get(at + key.len()..)?.trim_start();
|
||||
let after = after.strip_prefix('=')?.trim_start();
|
||||
let quote = after.chars().next()?;
|
||||
@@ -304,7 +375,10 @@ fn decode_text(raw: &str) -> String {
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
rest = &rest[at..];
|
||||
let search_window = rest.len().min(32);
|
||||
let mut search_window = rest.len().min(32);
|
||||
while !rest.is_char_boundary(search_window) {
|
||||
search_window -= 1;
|
||||
}
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
@@ -343,7 +417,7 @@ fn numeric_char(digits: &str) -> Option<char> {
|
||||
return None;
|
||||
}
|
||||
let n = u32::from_str_radix(body, radix).ok()?;
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
char::from_u32(n).filter(|c| !c.is_control() || matches!(*c, '\n' | '\t'))
|
||||
}
|
||||
|
||||
/// Do the HTML's words and the paste's plain text say the same thing?
|
||||
@@ -361,6 +435,42 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_on_other_tags_ends_at_their_close() {
|
||||
type Prop = fn(&RunProps) -> bool;
|
||||
let cases: [(&str, &str, Prop); 3] = [
|
||||
("a", "font-weight:bold", |p| p.bold == Some(true)),
|
||||
("td", "font-style:italic", |p| p.italic == Some(true)),
|
||||
("mark", "text-decoration:underline", |p| p.underline.is_some()),
|
||||
];
|
||||
for (tag, style, prop) in cases {
|
||||
let body = html_to_body(&format!("<p><{tag} style=\"{style}\">link</{tag}> plain</p>")).unwrap();
|
||||
let runs = &body.paragraphs[0].runs;
|
||||
assert_eq!(runs.len(), 2, "{tag}");
|
||||
assert!(prop(&runs[0].props), "{tag}");
|
||||
assert_eq!(runs[0].text, "link");
|
||||
assert_eq!(runs[1].text, " plain");
|
||||
assert!(runs[1].props.bold.is_none() && runs[1].props.italic.is_none() && runs[1].props.underline.is_none(), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_before_opening_block_tag_is_separate_paragraph() {
|
||||
for tag in ["div", "p", "li", "h1"] {
|
||||
let body = html_to_body(&format!("Hello<{tag}>world</{tag}>")).unwrap();
|
||||
let texts: Vec<String> = body.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect()).collect();
|
||||
assert_eq!(texts, ["Hello", "world"], "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_after_ampersand_is_kept() {
|
||||
let text = "é".repeat(20);
|
||||
assert_eq!(decode_text(&format!("R&D {text}")), format!("R&D {text}"));
|
||||
let cjk = "漢".repeat(20);
|
||||
assert_eq!(decode_text(&format!("&{cjk}")), format!("&{cjk}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_numeric_entities_are_kept_as_typed() {
|
||||
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
|
||||
@@ -368,6 +478,12 @@ mod tests {
|
||||
assert_eq!(decode_text("aAbA"), "aAbA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_tab_entity_decodes_to_tab() {
|
||||
assert_eq!(decode_text("a	b"), "a\tb");
|
||||
assert_eq!(decode_text("a	b"), "a\tb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
@@ -382,6 +498,20 @@ mod tests {
|
||||
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_attribute_is_found_after_attributes_containing_style() {
|
||||
for tag in [
|
||||
"<span class=\"style1\" style=\"font-weight:bold\">x</span>",
|
||||
"<span data-style=\"x\" style=\"font-weight:bold\">x</span>",
|
||||
"<span class=\"mystyle\" style=\"font-weight:bold\">x</span>",
|
||||
] {
|
||||
let body = html_to_body(tag).unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{tag}");
|
||||
}
|
||||
let none = html_to_body("<span class=\"style\">x</span>").unwrap();
|
||||
assert_eq!(none.paragraphs[0].runs[0].props.bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
@@ -401,6 +531,16 @@ mod tests {
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_escaped_rich_text_puts_no_html_on_the_clipboard() {
|
||||
let mut body = TextBody::from_text(&"&<🦀".repeat(100_000));
|
||||
body.paragraphs[0].runs[0].props.bold = Some(true);
|
||||
body.paragraphs.push(Paragraph::new("later paragraph"));
|
||||
|
||||
// Neither the formatted nor the plain HTML fits, and a truncated prefix would lose text.
|
||||
assert!(body_to_html(&body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_slide, id_param, ids_param, ok};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
|
||||
+193
-1201
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
while adj.len() <= i {
|
||||
let k = adj.len();
|
||||
adj.push(defaults.get(k).copied().unwrap_or(0.0));
|
||||
}
|
||||
if let Some(slot) = adj.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed
|
||||
/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says.
|
||||
pub(super) fn smooth_corners(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "shape.smoothCorners";
|
||||
let smoothing =
|
||||
f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0;
|
||||
let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0));
|
||||
edit_geometry(s, p, CMD, |sh| {
|
||||
let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) };
|
||||
let ss = x.w.min(x.h).max(0.0);
|
||||
let own = match &sh.geom {
|
||||
Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0),
|
||||
Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
|
||||
_ => None,
|
||||
};
|
||||
// A custom outline qualifies only when it is a squircle this command drew with `radius`
|
||||
// (re-smoothing as the slider moves), never an arbitrary freeform.
|
||||
let custom = match (&sh.geom, radius) {
|
||||
(Geom::Custom { paths }, Some(r)) => match paths.as_slice() {
|
||||
[only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
let r = match (radius, own) {
|
||||
(Some(r), _) if own.is_some() || custom => r,
|
||||
(None, Some(r)) => r,
|
||||
_ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")),
|
||||
};
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath {
|
||||
w: x.w,
|
||||
h: x.h,
|
||||
d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing),
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
edit_geometry(s, p, "shape.change", |sh| {
|
||||
sh.geom = Geom::preset(info.name);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
|
||||
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
|
||||
/// rotateWithShape?}`.
|
||||
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
let stops: Vec<GradientStop> = v
|
||||
.get("stops")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let a = s.as_array()?;
|
||||
Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? })
|
||||
})
|
||||
.collect();
|
||||
if stops.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let focus = v
|
||||
.get("focus")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| {
|
||||
let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||||
Some([f(0)?, f(1)?, f(2)?, f(3)?])
|
||||
})
|
||||
.unwrap_or([0.5, 0.5, 0.5, 0.5]);
|
||||
let mut stops = stops;
|
||||
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
let path = |p: &str| GradientShape::Path { path: p.into(), focus };
|
||||
let shape = match v.get("kind").and_then(Value::as_str) {
|
||||
Some("radial" | "circle") => path("circle"),
|
||||
Some("rectangular" | "rect") => path("rect"),
|
||||
Some("path" | "shape") => path("shape"),
|
||||
_ => GradientShape::Linear {
|
||||
angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0),
|
||||
scaled: false,
|
||||
},
|
||||
};
|
||||
Some(Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops,
|
||||
shape,
|
||||
rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true),
|
||||
flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
|
||||
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Ok(());
|
||||
}
|
||||
f(sh)?;
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for child in children {
|
||||
apply(child, depth + 1, f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
|
||||
}
|
||||
|
||||
pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_fill = requested_fill(s, p)?;
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
|
||||
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
|
||||
return Err(bad(
|
||||
"shape.fill",
|
||||
"give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`",
|
||||
));
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let inherited = inherited_fills(s, p)?;
|
||||
let background = matches!(new_fill, Some(Some(Fill::Background)));
|
||||
if background {
|
||||
let st = s.doc()?;
|
||||
let mut any = false;
|
||||
for id in targets(s, p)? {
|
||||
if let Some(sh) = st.shape(id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape));
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes"));
|
||||
}
|
||||
}
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||||
if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) {
|
||||
if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) {
|
||||
sh.fill = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)
|
||||
})
|
||||
}
|
||||
|
||||
/// Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures,
|
||||
/// connectors, tables and charts keep their fill, and a group's own fill is cleared unless one
|
||||
/// of them is filled with it (`grpFill`).
|
||||
fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool {
|
||||
depth <= 64
|
||||
&& sh.children().iter().any(|c| match c.kind {
|
||||
deckcraft_model::ShapeKind::Shape => false,
|
||||
deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1),
|
||||
_ => c.fill == Some(Fill::Group),
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
|
||||
pub(super) fn picture_props(pf: &mut PictureFill, pp: &Value) {
|
||||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||||
pf.upright = !up;
|
||||
}
|
||||
match pp.get("tile").and_then(Value::as_bool) {
|
||||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
}
|
||||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||||
_ => {}
|
||||
}
|
||||
match &mut pf.mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
if let Some(v) = f("offsetX") {
|
||||
*tx = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("offsetY") {
|
||||
*ty = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("scaleX") {
|
||||
*sx = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(v) = f("scaleY") {
|
||||
*sy = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||||
*flip = m.to_string();
|
||||
}
|
||||
if let Some(a) =
|
||||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||||
{
|
||||
*align = a.to_string();
|
||||
}
|
||||
}
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||||
*slot = v.clamp(-10.0, 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_end(v: &str) -> Option<LineEnd> {
|
||||
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
|
||||
}
|
||||
|
||||
pub(super) fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let none = bool_or(p, "none", false);
|
||||
let color = color_param(p, "color");
|
||||
let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0));
|
||||
let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() });
|
||||
let head = str_param(p, "head").map(line_end);
|
||||
let tail = str_param(p, "tail").map(line_end);
|
||||
let cap = str_param(p, "cap").map(|c| match c {
|
||||
"round" => deckcraft_model::style::LineCap::Round,
|
||||
"square" => deckcraft_model::style::LineCap::Square,
|
||||
_ => deckcraft_model::style::LineCap::Flat,
|
||||
});
|
||||
let compound = str_param(p, "compound").map(|c| match c {
|
||||
"double" => deckcraft_model::style::Compound::Double,
|
||||
"thickThin" => deckcraft_model::style::Compound::ThickThin,
|
||||
"thinThick" => deckcraft_model::style::Compound::ThinThick,
|
||||
"triple" => deckcraft_model::style::Compound::Triple,
|
||||
_ => deckcraft_model::style::Compound::Single,
|
||||
});
|
||||
let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string);
|
||||
let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize"));
|
||||
let join = str_param(p, "join").map(|c| match c {
|
||||
"bevel" => deckcraft_model::style::LineJoin::Bevel,
|
||||
"miter" => deckcraft_model::style::LineJoin::Miter,
|
||||
_ => deckcraft_model::style::LineJoin::Round,
|
||||
});
|
||||
edit_group_appearance(s, p, "shape.line", |sh| {
|
||||
if reset {
|
||||
sh.line = None;
|
||||
return Ok(());
|
||||
}
|
||||
let l = sh.line.get_or_insert_with(Line::default);
|
||||
if none {
|
||||
l.fill = Some(Fill::None);
|
||||
}
|
||||
if let Some(c) = &color {
|
||||
l.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
if width.is_some() {
|
||||
l.width = width;
|
||||
if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none {
|
||||
l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)));
|
||||
}
|
||||
}
|
||||
if dash.is_some() {
|
||||
l.dash.clone_from(&dash);
|
||||
}
|
||||
let keep_size = |old: &Option<LineEnd>, new: &Option<LineEnd>| {
|
||||
new.clone().map(|mut n| {
|
||||
if let Some(o) = old {
|
||||
n.w.clone_from(&o.w);
|
||||
n.len.clone_from(&o.len);
|
||||
}
|
||||
n
|
||||
})
|
||||
};
|
||||
if let Some(h) = &head {
|
||||
l.head = keep_size(&l.head, h);
|
||||
}
|
||||
if let Some(t) = &tail {
|
||||
l.tail = keep_size(&l.tail, t);
|
||||
}
|
||||
if cap.is_some() {
|
||||
l.cap = cap;
|
||||
}
|
||||
if join.is_some() {
|
||||
l.join = join;
|
||||
}
|
||||
if compound.is_some() {
|
||||
l.compound = compound;
|
||||
}
|
||||
// Arrowhead size (width and length together), kept when the kind changes.
|
||||
for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] {
|
||||
if let (Some(e), Some(sz)) = (end.as_mut(), size) {
|
||||
e.w.clone_from(sz);
|
||||
e.len.clone_from(sz);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn shadow_preset(v: &Value) -> Option<Option<Shadow>> {
|
||||
let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a));
|
||||
let base = |inner: bool| Shadow {
|
||||
color: black(40000),
|
||||
blur: 4.0,
|
||||
dist: 3.0,
|
||||
dir: 45.0,
|
||||
inner,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: "tl".into(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
match v {
|
||||
Value::String(s) => Some(match s.as_str() {
|
||||
"none" => None,
|
||||
"inner" => Some(base(true)),
|
||||
"perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }),
|
||||
"bottom" => Some(Shadow { dir: 90.0, ..base(false) }),
|
||||
"center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }),
|
||||
_ => Some(base(false)),
|
||||
}),
|
||||
Value::Object(o) => Some(Some(Shadow {
|
||||
color: o.get("color").and_then(color_value).unwrap_or(black(40000)),
|
||||
blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0),
|
||||
dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0),
|
||||
dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0),
|
||||
inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false),
|
||||
..base(false)
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let shadow = p.get("shadow").and_then(shadow_preset);
|
||||
let glow: Option<Option<Glow>> = p.get("glow").map(|g| match g {
|
||||
Value::Object(o) => Some(Glow {
|
||||
color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))),
|
||||
radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0),
|
||||
}),
|
||||
Value::Number(n) => Some(Glow {
|
||||
color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)),
|
||||
radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0),
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
|
||||
let refl = str_param(p, "reflection").map(|r| match r {
|
||||
"none" => None,
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
});
|
||||
let three_d = p.get("threeD").filter(|v| v.is_object());
|
||||
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
|
||||
let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned();
|
||||
let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned();
|
||||
let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned();
|
||||
edit_shapes(s, p, "shape.effects", |sh| {
|
||||
if reset {
|
||||
sh.effects = None;
|
||||
return Ok(());
|
||||
}
|
||||
let e = sh.effects.get_or_insert_with(Effects::default);
|
||||
if let Some(sw) = &shadow {
|
||||
match sw {
|
||||
Some(x) if x.inner => {
|
||||
e.inner_shadow = Some(x.clone());
|
||||
e.outer_shadow = None;
|
||||
}
|
||||
Some(x) => {
|
||||
e.outer_shadow = Some(x.clone());
|
||||
e.inner_shadow = None;
|
||||
}
|
||||
None => {
|
||||
e.outer_shadow = None;
|
||||
e.inner_shadow = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(g) = &glow {
|
||||
e.glow.clone_from(g);
|
||||
}
|
||||
if let Some(sf) = soft {
|
||||
e.soft_edge = sf;
|
||||
}
|
||||
if let Some(r) = &refl {
|
||||
e.reflection.clone_from(r);
|
||||
}
|
||||
if bevel.is_some() || three_d.is_some() {
|
||||
let mut s3 = e.raw3d.as_deref().map(crate::cmd::three_d::Shape3d::parse).unwrap_or_default();
|
||||
if let Some(b) = &bevel {
|
||||
s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")}));
|
||||
}
|
||||
if let Some(t) = three_d {
|
||||
if t.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
s3 = crate::cmd::three_d::Shape3d::default();
|
||||
}
|
||||
s3.apply(t);
|
||||
}
|
||||
crate::cmd::three_d::store(e, &s3);
|
||||
}
|
||||
if let Some(v) = &shadow_props {
|
||||
let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow };
|
||||
let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?;
|
||||
if let Some(c) = v.get("color").and_then(color_value) {
|
||||
let a = x.color.alpha();
|
||||
x.color = with_alpha(c, a);
|
||||
}
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(b) = num(v, "blur") {
|
||||
x.blur = b.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(d) = num(v, "dist") {
|
||||
x.dist = d.clamp(0.0, 200.0);
|
||||
}
|
||||
if let Some(d) = num(v, "dir") {
|
||||
x.dir = d.rem_euclid(360.0);
|
||||
}
|
||||
// PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.
|
||||
if let Some(z) = num(v, "size") {
|
||||
let z = z.clamp(0.01, 2.0);
|
||||
x.sx = z;
|
||||
x.sy = z;
|
||||
}
|
||||
*slot = Some(x);
|
||||
}
|
||||
if let Some(v) = &glow_props {
|
||||
let mut g =
|
||||
e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 });
|
||||
if let Some(c) = v.get("color").and_then(color_value) {
|
||||
let a = g.color.alpha();
|
||||
g.color = with_alpha(c, a);
|
||||
}
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(r) = num(v, "radius") {
|
||||
g.radius = r.clamp(0.0, 150.0);
|
||||
}
|
||||
e.glow = (g.radius > 0.0).then_some(g);
|
||||
}
|
||||
if let Some(v) = &refl_props {
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(sz) = num(v, "size") {
|
||||
r.end_pos = sz.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(d) = num(v, "dist") {
|
||||
r.dist = d.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(b) = num(v, "blur") {
|
||||
r.blur = b.clamp(0.0, 100.0);
|
||||
}
|
||||
e.reflection = Some(r);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// A finite number at `key` of an effect's properties.
|
||||
fn num(v: &Value, key: &str) -> Option<f64> {
|
||||
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform.
|
||||
fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef {
|
||||
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||||
if a < 1.0 {
|
||||
c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents.
|
||||
pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>) {
|
||||
let slots = [
|
||||
SchemeSlot::Dk1,
|
||||
SchemeSlot::Accent1,
|
||||
SchemeSlot::Accent2,
|
||||
SchemeSlot::Accent3,
|
||||
SchemeSlot::Accent4,
|
||||
SchemeSlot::Accent5,
|
||||
SchemeSlot::Accent6,
|
||||
];
|
||||
let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1);
|
||||
let row = index / 7;
|
||||
let mut st = ShapeStyle::accent(slot);
|
||||
let c = ColorRef::scheme(slot);
|
||||
match row {
|
||||
0 => {
|
||||
// Colored outline, light fill.
|
||||
st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
|
||||
st.line_ref = (2, c);
|
||||
st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1));
|
||||
(st, None, None)
|
||||
}
|
||||
1 => (st, None, None),
|
||||
2 => {
|
||||
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000)));
|
||||
st.line_ref = (0, c.clone());
|
||||
st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000)));
|
||||
(st, None, Some(Line::none()))
|
||||
}
|
||||
3 => {
|
||||
st.fill_ref = (2, c);
|
||||
(st, None, Some(Line::none()))
|
||||
}
|
||||
4 => {
|
||||
st.fill_ref = (3, c.clone());
|
||||
st.effect_ref = (3, c);
|
||||
(st, None, Some(Line::none()))
|
||||
}
|
||||
_ => {
|
||||
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000)));
|
||||
st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
|
||||
(st, None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
||||
let (st, f, l) = quick_style_for(i);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
sh.fill.clone_from(&f);
|
||||
sh.line.clone_from(&l);
|
||||
sh.effects = None;
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
deckcraft_model::edit::format_all(t, &|r| r.fill = None);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gradient_parameters_keep_flip_mode() {
|
||||
let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient");
|
||||
assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param};
|
||||
|
||||
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let st = s.doc()?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {
|
||||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||||
let (a, b) = (site_list(from), site_list(to));
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
|
||||
}
|
||||
let pick = |v: &[deckcraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len());
|
||||
let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) {
|
||||
(Some(i), Some(j)) => (i, j),
|
||||
(fi, fj) => {
|
||||
let mut best = (f64::MAX, 0, 0);
|
||||
for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) {
|
||||
for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) {
|
||||
let d = (*pa - *pb).hypot();
|
||||
if d < best.0 {
|
||||
best = (d, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
(best.1, best.2)
|
||||
}
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?;
|
||||
deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32)
|
||||
}
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?;
|
||||
if !sh.is_line() {
|
||||
return Err(bad("shape.connect", "`id` is not a line or connector"));
|
||||
}
|
||||
sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0));
|
||||
sh.kind = deckcraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) };
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))
|
||||
}
|
||||
|
||||
/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics.
|
||||
pub fn freeform_path(pts: &[deckcraft_geom::Point], closed: bool, smooth: bool) -> String {
|
||||
let f = |v: f64| {
|
||||
let s = format!("{:.2}", v);
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
};
|
||||
let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y));
|
||||
let n = pts.len();
|
||||
if smooth && n > 2 {
|
||||
let at = |i: isize| -> deckcraft_geom::Point {
|
||||
if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] }
|
||||
};
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs as isize {
|
||||
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
|
||||
let c1 = p1 + (p2 - p0) / 6.0;
|
||||
let c2 = p2 - (p3 - p1) / 6.0;
|
||||
d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y));
|
||||
}
|
||||
} else {
|
||||
for q in &pts[1..] {
|
||||
d += &format!(" L {} {}", f(q.x), f(q.y));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
d += " Z";
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts: Vec<deckcraft_geom::Point> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||||
.take(20_000)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("shape.freeform", "need at least two points"));
|
||||
}
|
||||
let closed = bool_or(p, "closed", false) && pts.len() > 2;
|
||||
let smooth = bool_or(p, "smooth", false);
|
||||
let mut bb = deckcraft_geom::Rect::from_points(pts[0], pts[0]);
|
||||
for q in &pts {
|
||||
bb = bb.union_pt(*q);
|
||||
}
|
||||
let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));
|
||||
let local: Vec<deckcraft_geom::Point> = pts.iter().map(|q| deckcraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
let d = freeform_path(&local, closed, smooth);
|
||||
// Closed shapes take the default shape style, open paths the line style.
|
||||
let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?;
|
||||
let id = deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32);
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?;
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath {
|
||||
w,
|
||||
h,
|
||||
d: d.clone(),
|
||||
fill: if closed { deckcraft_geom::preset::FillMode::Norm } else { deckcraft_geom::preset::FillMode::None },
|
||||
stroke: true,
|
||||
}],
|
||||
};
|
||||
sh.xfrm = Some(deckcraft_geom::Xfrm::new(bb.x0, bb.y0, w, h));
|
||||
sh.kind = deckcraft_model::ShapeKind::Shape;
|
||||
sh.name = format!("Freeform {}", id.0);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape
|
||||
/// outlined in the stroke's colour and width; the rest stays ink. One undo step.
|
||||
pub(super) fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
use crate::inkshape::{Recognized, recognize};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
let shapes = s.doc()?.shapes();
|
||||
let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {
|
||||
return Ok(json!({"shapes": [], "kept": 0}));
|
||||
};
|
||||
let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));
|
||||
let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };
|
||||
let found: Vec<Option<Recognized>> = strokes
|
||||
.iter()
|
||||
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
|
||||
.collect();
|
||||
if found.iter().all(Option::is_none) {
|
||||
return Ok(json!({"shapes": [], "kept": strokes.len()}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mut new = vec![];
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };
|
||||
let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };
|
||||
let all = std::mem::take(strokes);
|
||||
let mut kept = vec![];
|
||||
let mut made = vec![];
|
||||
for (st, r) in all.into_iter().zip(found.iter()) {
|
||||
let Some(r) = r else {
|
||||
kept.push(st);
|
||||
continue;
|
||||
};
|
||||
let (preset, xfrm) = match *r {
|
||||
Recognized::Line { x0, y0, x1, y1 } => {
|
||||
("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))
|
||||
}
|
||||
Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),
|
||||
};
|
||||
made.push((preset, xfrm, st.color, st.width));
|
||||
}
|
||||
let remaining = kept.len();
|
||||
*strokes = kept;
|
||||
if remaining == 0 {
|
||||
list.remove(pos);
|
||||
}
|
||||
for (preset, xfrm, color, width) in made {
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("draw.inkToShape", NO_ID_SPACE))?);
|
||||
let name = format!(
|
||||
"{} {}",
|
||||
match preset {
|
||||
"line" => "Straight Connector",
|
||||
"rect" => "Rectangle",
|
||||
"ellipse" => "Oval",
|
||||
_ => "Isosceles Triangle",
|
||||
},
|
||||
id.0
|
||||
);
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
list.push(Shape {
|
||||
id,
|
||||
name,
|
||||
xfrm: Some(xfrm),
|
||||
geom: Geom::preset(preset),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),
|
||||
kind: ShapeKind::Shape,
|
||||
..Default::default()
|
||||
});
|
||||
new.push(id);
|
||||
}
|
||||
sel.shapes = new.clone();
|
||||
Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::{NO_ID_SPACE, Result, Session, Value, bad, bool_or, bool_param, edit_geometry, edit_shapes, f64_param, json};
|
||||
|
||||
pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||
edit_geometry(s, p, "picture.crop", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
for (i, v) in c.iter().enumerate() {
|
||||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||||
*slot = v.clamp(-10.0, 0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn picture_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "picture.adjust", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
let a = &mut fill.adjust;
|
||||
if bool_or(p, "reset", false) {
|
||||
*a = Default::default();
|
||||
}
|
||||
if let Some(v) = f64_param(p, "brightness") {
|
||||
a.brightness = v.clamp(-1.0, 1.0);
|
||||
}
|
||||
if let Some(v) = f64_param(p, "contrast") {
|
||||
a.contrast = v.clamp(-1.0, 1.0);
|
||||
}
|
||||
if let Some(v) = f64_param(p, "saturation") {
|
||||
a.saturation = Some(v.clamp(0.0, 4.0));
|
||||
}
|
||||
if let Some(v) = bool_param(p, "grayscale") {
|
||||
a.grayscale = v;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "transparency") {
|
||||
fill.alpha = Some((1.0 - v).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn picture_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "picture.reset", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.adjust = Default::default();
|
||||
fill.crop = [0.0; 4];
|
||||
fill.alpha = None;
|
||||
if let Some(a) = fill.artistic.take() {
|
||||
fill.media = a.original;
|
||||
}
|
||||
}
|
||||
sh.effects = None;
|
||||
sh.line = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn picture_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = crate::cmd::insert::media_bytes(p, "picture.change")?;
|
||||
let ct = crate::cmd::insert::content_type(&name, &bytes);
|
||||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?;
|
||||
edit_shapes(s, p, "picture.change", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.media = media;
|
||||
fill.crop = [0.0; 4];
|
||||
fill.artistic = None;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "media.options", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Media(m) = &mut sh.kind {
|
||||
if let Some(v) = bool_param(p, "autoplay") {
|
||||
m.autoplay = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "loop") {
|
||||
m.loop_play = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "rewind") {
|
||||
m.rewind = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "acrossSlides") {
|
||||
m.play_across_slides = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "hide") {
|
||||
m.hide_while_not_playing = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "fullScreen") {
|
||||
m.full_screen = v;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "volume") {
|
||||
m.volume = v.clamp(0.0, 1.0);
|
||||
}
|
||||
let ms = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32);
|
||||
if let Some(v) = ms("trimStart") {
|
||||
m.trim_start_ms = v;
|
||||
}
|
||||
if let Some(v) = ms("trimEnd") {
|
||||
m.trim_end_ms = v;
|
||||
}
|
||||
if let Some(v) = ms("fadeIn") {
|
||||
m.fade_in_ms = v;
|
||||
}
|
||||
if let Some(v) = ms("fadeOut") {
|
||||
m.fade_out_ms = v;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use super::{Result, Session, Value, bad, edit_geometry, f64_param, json, str_param, targets, xfrm_of};
|
||||
|
||||
/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
|
||||
/// An overflow to infinity lands on the limit; NaN becomes 0.
|
||||
pub(super) fn clamp_side(v: f64) -> f64 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) }
|
||||
}
|
||||
|
||||
/// Keeps a coordinate within `±POS_LIMIT`.
|
||||
pub(super) fn clamp_pos(v: f64) -> f64 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) }
|
||||
}
|
||||
|
||||
const SIZE_POS: &str = "shape.sizePosition";
|
||||
|
||||
/// Smallest and largest side the dialog will write, in points (220 in).
|
||||
const MIN_PT: f64 = 0.01;
|
||||
|
||||
const MAX_PT: f64 = 15_840.0;
|
||||
|
||||
const POS_LIMIT: f64 = 100_000.0;
|
||||
|
||||
const MAX_SCALE: f64 = 10_000.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SizeSpec {
|
||||
pub(crate) x: Option<f64>,
|
||||
pub(crate) y: Option<f64>,
|
||||
pub(crate) w: Option<f64>,
|
||||
pub(crate) h: Option<f64>,
|
||||
pub(crate) rot: Option<f64>,
|
||||
scale_w: Option<f64>,
|
||||
scale_h: Option<f64>,
|
||||
lock: bool,
|
||||
}
|
||||
|
||||
impl SizeSpec {
|
||||
fn width_set(self) -> bool {
|
||||
self.w.is_some() || self.scale_w.is_some()
|
||||
}
|
||||
fn height_set(self) -> bool {
|
||||
self.h.is_some() || self.scale_h.is_some()
|
||||
}
|
||||
fn any(self) -> bool {
|
||||
self.x.is_some() || self.y.is_some() || self.width_set() || self.height_set() || self.rot.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_key(p: &Value, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_f64() {
|
||||
Some(n) if n.is_finite() => Ok(Some(n)),
|
||||
_ => Err(bad(SIZE_POS, format!("`{key}` must be a finite number"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(v: Option<f64>, key: &str, lo: f64, hi: f64) -> Result<Option<f64>> {
|
||||
if let Some(n) = v
|
||||
&& !(lo..=hi).contains(&n)
|
||||
{
|
||||
return Err(bad(SIZE_POS, format!("`{key}` is out of range")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn size_spec(p: &Value) -> Result<SizeSpec> {
|
||||
let lock = match p.get("lockAspect") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(SIZE_POS, "`lockAspect` must be true or false")),
|
||||
};
|
||||
Ok(SizeSpec {
|
||||
x: in_range(finite_key(p, "x")?, "x", -POS_LIMIT, POS_LIMIT)?,
|
||||
y: in_range(finite_key(p, "y")?, "y", -POS_LIMIT, POS_LIMIT)?,
|
||||
w: in_range(finite_key(p, "w")?, "w", MIN_PT, MAX_PT)?,
|
||||
h: in_range(finite_key(p, "h")?, "h", MIN_PT, MAX_PT)?,
|
||||
rot: finite_key(p, "rot")?,
|
||||
scale_w: in_range(finite_key(p, "scaleW")?, "scaleW", f64::MIN_POSITIVE, MAX_SCALE)?,
|
||||
scale_h: in_range(finite_key(p, "scaleH")?, "scaleH", f64::MIN_POSITIVE, MAX_SCALE)?,
|
||||
lock,
|
||||
})
|
||||
}
|
||||
|
||||
fn norm_deg(r: f64) -> f64 {
|
||||
let r = r % 360.0;
|
||||
if r < 0.0 { r + 360.0 } else { r }
|
||||
}
|
||||
|
||||
/// Width wins when both sides are set with the aspect locked, matching `shape.resize`.
|
||||
fn apply_size(x: &mut deckcraft_geom::Xfrm, spec: SizeSpec) {
|
||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||
let mut nw = x.w;
|
||||
let mut nh = x.h;
|
||||
if let Some(p) = spec.scale_w {
|
||||
nw = x.w * p / 100.0;
|
||||
}
|
||||
if let Some(p) = spec.scale_h {
|
||||
nh = x.h * p / 100.0;
|
||||
}
|
||||
if let Some(w) = spec.w {
|
||||
nw = w;
|
||||
}
|
||||
if let Some(h) = spec.h {
|
||||
nh = h;
|
||||
}
|
||||
if spec.lock && (spec.width_set() || spec.height_set()) {
|
||||
if spec.width_set() {
|
||||
nh = if ar > 0.0 { nw / ar } else { nh };
|
||||
} else {
|
||||
nw = nh * ar;
|
||||
}
|
||||
}
|
||||
x.w = nw.clamp(MIN_PT, MAX_PT);
|
||||
x.h = nh.clamp(MIN_PT, MAX_PT);
|
||||
if let Some(px) = spec.x {
|
||||
x.x = px;
|
||||
}
|
||||
if let Some(py) = spec.y {
|
||||
x.y = py;
|
||||
}
|
||||
if let Some(r) = spec.rot {
|
||||
x.rot = norm_deg(r);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn size_position(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let spec = size_spec(p)?;
|
||||
if !spec.any() {
|
||||
let (x, y, w, h, rot) = {
|
||||
let st = s.doc()?;
|
||||
let id = targets(s, p)?.first().copied().ok_or_else(|| bad(SIZE_POS, "nothing selected"))?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad(SIZE_POS, "no such shape"))?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
(x.x, x.y, x.w, x.h, x.rot)
|
||||
};
|
||||
s.ui_requests.push(crate::UiRequest::Dialog {
|
||||
id: "sizePosition".into(),
|
||||
params: json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "scaleW": 100.0, "scaleH": 100.0, "lockAspect": false}),
|
||||
});
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let changed = {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<_> = ids.iter().copied().filter(|id| !st.shape(*id).is_some_and(|sh| sh.locked)).collect::<Vec<_>>();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(SIZE_POS, "the object is locked"));
|
||||
}
|
||||
free.iter().any(|id| {
|
||||
st.shape(*id).is_some_and(|sh| {
|
||||
let mut x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let before = x;
|
||||
apply_size(&mut x, spec);
|
||||
x != before
|
||||
})
|
||||
})
|
||||
};
|
||||
if !changed {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
edit_geometry(s, p, SIZE_POS, |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
apply_size(x, spec);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A trackpad twist sends many small steps. `gesture: move` keeps them as one undo;
|
||||
// `gesture: end` commits that step and does not rotate again.
|
||||
if p.get("gesture").and_then(Value::as_str) == Some("end") {
|
||||
s.commit_named("Rotation");
|
||||
return Ok(json!({}));
|
||||
}
|
||||
if p.get("gesture").and_then(Value::as_str) == Some("move") {
|
||||
s.begin("Rotation");
|
||||
}
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
} else {
|
||||
x.flip_h = !x.flip_h;
|
||||
}
|
||||
// `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation.
|
||||
x.rot = (360.0 - x.rot_norm()) % 360.0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod size_position_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn deck() -> (Session, u32) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) {
|
||||
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
|
||||
let b = &v["box"];
|
||||
(b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn undos(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_position_size_and_rotation_in_one_undo() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0));
|
||||
assert_eq!(undos(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_aspect_and_scale_share_one_step() {
|
||||
let (mut s, id) = deck();
|
||||
s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));
|
||||
// An explicit width wins over a scale on the same axis.
|
||||
s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id).2, 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0));
|
||||
assert_eq!(undos(&s), n + 1);
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap();
|
||||
assert_eq!(undos(&s), n, "identical geometry is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fields_opens_the_dialog_without_editing() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
assert_eq!(undos(&s), n);
|
||||
let req = s.ui_requests.last().unwrap();
|
||||
match req {
|
||||
crate::UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "sizePosition");
|
||||
assert_eq!(params["w"], 200.0);
|
||||
assert_eq!(params["h"], 100.0);
|
||||
assert_eq!(params["x"], 40.0);
|
||||
assert_eq!(params["rot"], 0.0);
|
||||
assert_eq!(params["scaleW"], 100.0);
|
||||
assert_eq!(params["lockAspect"], false);
|
||||
}
|
||||
other => panic!("expected a dialog, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_fields_do_not_edit() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
for p in [
|
||||
json!({"w": "wide"}),
|
||||
json!({"h": -5.0}),
|
||||
json!({"scaleW": 0.0}),
|
||||
json!({"x": 1e9}),
|
||||
json!({"rot": "spin"}),
|
||||
json!({"lockAspect": "yes"}),
|
||||
] {
|
||||
let err = s.execute("shape.sizePosition", &p).unwrap_err();
|
||||
assert!(err.to_string().contains("sizePosition"), "{err}");
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
assert_eq!(undos(&s), n);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_is_per_shape_and_nothing_selected_is_disabled() {
|
||||
let (mut s, a) = deck();
|
||||
let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, a).2, 400.0);
|
||||
assert_eq!(geom(&mut s, a).3, 100.0);
|
||||
assert_eq!(geom(&mut s, b).2, 80.0);
|
||||
assert_eq!(geom(&mut s, b).3, 80.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let err = s.execute("shape.sizePosition", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("nothing selected"), "{err}");
|
||||
}
|
||||
}
|
||||
+322
-24
@@ -1,12 +1,13 @@
|
||||
//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, has_doc, has_slide, ok, opt_usize_param, str_param, usize_param};
|
||||
use crate::{Result, Session, Target};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -22,12 +23,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate),
|
||||
cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete),
|
||||
cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide),
|
||||
cmd!(
|
||||
"slide.move",
|
||||
"Move Slide",
|
||||
[],
|
||||
None,
|
||||
"{from?: index, ids?: [slide id], to: index (counted without the moved slides)}",
|
||||
has_slide,
|
||||
move_slide
|
||||
),
|
||||
cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go),
|
||||
cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next),
|
||||
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
|
||||
cmd!(noundo "slide.first", "First Slide", [], Some("Home"), "{}", has_slide, first),
|
||||
cmd!(noundo "slide.last", "Last Slide", [], Some("End"), "{}", has_slide, last),
|
||||
cmd!(noundo "slide.selectRange", "Select Slide Range", [], None, "{to: index, from?: index (default the current slide), add?: bool (keep the slides already selected)}", has_slide, select_range),
|
||||
cmd!(noundo "slide.selectSlides", "Select Slides", [], None, "{ids?: [slide id], indices?: [index], add?: bool}", has_slide, select_slides),
|
||||
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
|
||||
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
|
||||
@@ -265,36 +275,75 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves one slide (`from`, else the current slide) or several (`ids`) to `to`, in one undo step.
|
||||
/// `to` counts in the deck without the moved slides; they land there as one run, in the order
|
||||
/// they had, join the section of the slide before them, and stay selected.
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let ids = p.get("ids").filter(|v| !v.is_null());
|
||||
let from = match ids {
|
||||
None => opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide),
|
||||
Some(_) => 0,
|
||||
};
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
return Err(bad("slide.move", format!("no slide {from}")));
|
||||
}
|
||||
let sl = doc.slides.remove(from);
|
||||
let to = to.min(doc.slides.len());
|
||||
let id = sl.id;
|
||||
doc.slides.insert(to, sl);
|
||||
// Keep sections consistent with order: move the id into the section of its new neighbour.
|
||||
if !doc.sections.is_empty() {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| *x != id);
|
||||
let moving: HashSet<SlideId> = match ids {
|
||||
Some(v) => slide_ids_param(v, doc, "slide.move")?,
|
||||
None => {
|
||||
let id = doc.slides.get(from).map(|x| x.id).ok_or_else(|| bad("slide.move", format!("no slide {from}")))?;
|
||||
HashSet::from([id])
|
||||
}
|
||||
let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.push(id);
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
};
|
||||
let (block, mut rest): (Vec<_>, Vec<_>) = std::mem::take(&mut doc.slides).into_iter().partition(|x| moving.contains(&x.id));
|
||||
let to = to.min(rest.len());
|
||||
let tail = rest.split_off(to);
|
||||
let moved: Vec<SlideId> = block.iter().map(|x| x.id).collect();
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
doc.slides = rest;
|
||||
reassign_sections(doc, &moved, to);
|
||||
sel.slide = to;
|
||||
sel.slides = vec![id];
|
||||
Ok(json!({"index": to}))
|
||||
sel.slides = moved;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"index": to, "moved": moving.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The slides an `ids` param names: a non-empty list of ids of slides in `doc` (repeats fold).
|
||||
/// The first id that is not a slide of `doc` is an error, so the set never outgrows the deck.
|
||||
fn slide_ids_param(v: &Value, doc: &Presentation, cmd: &str) -> Result<HashSet<SlideId>> {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, "`ids` must be a list of slide ids"))?;
|
||||
let known: HashSet<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
let mut out = HashSet::new();
|
||||
for x in a {
|
||||
let id = x.as_u64().and_then(|n| u32::try_from(n).ok()).map(SlideId).filter(|id| known.contains(id));
|
||||
out.insert(id.ok_or_else(|| bad(cmd, format!("no slide with id {x}")))?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, "`ids` is empty"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Keeps sections in step after `moved` landed as a run at `at`: the run joins the section of
|
||||
/// the slide just before it (the first section when it opens the deck), in document order.
|
||||
fn reassign_sections(doc: &mut Presentation, moved: &[SlideId], at: usize) {
|
||||
if doc.sections.is_empty() {
|
||||
return;
|
||||
}
|
||||
let moving: HashSet<SlideId> = moved.iter().copied().collect();
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !moving.contains(x));
|
||||
}
|
||||
let neighbour = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: HashMap<SlideId, usize> = doc.slides.iter().enumerate().map(|(i, x)| (x.id, i)).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.extend_from_slice(moved);
|
||||
sec.slides.sort_by_key(|x| order.get(x).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, i: usize) -> Result<Value> {
|
||||
s.select(|doc, sel| {
|
||||
let n = doc.slides.len();
|
||||
@@ -303,6 +352,7 @@ fn go_to(s: &mut Session, i: usize) -> Result<Value> {
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.range_anchor = None;
|
||||
sel.target = Target::Slides;
|
||||
})?;
|
||||
Ok(json!({"index": s.doc()?.selection.slide}))
|
||||
@@ -368,6 +418,47 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
sel.range_anchor = None;
|
||||
})?;
|
||||
Ok(json!({"selected": s.doc()?.selection.slides}))
|
||||
}
|
||||
|
||||
/// Shift-click in the slide list: select every slide from `from` to `to`. Without `from` the range
|
||||
/// starts at the anchor of the previous Shift-click (so a second Shift-click extends from the same
|
||||
/// slide, as in PowerPoint), else at the current slide.
|
||||
/// With `add` the slides already selected stay selected (Ctrl+Shift-click).
|
||||
fn select_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.selectRange", "missing `to`"))?;
|
||||
let given = opt_usize_param(p, "from", "slide.selectRange")?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let (from, count) = {
|
||||
let st = s.doc()?;
|
||||
let sel = &st.selection;
|
||||
let current = st.doc.slides.get(sel.slide).map(|x| x.id);
|
||||
let anchor = sel.range_anchor.filter(|(_, end)| Some(*end) == current).and_then(|(a, _)| st.doc.slide_index(a));
|
||||
(given.or(anchor).unwrap_or(sel.slide), st.doc.slides.len())
|
||||
};
|
||||
if to >= count || from >= count {
|
||||
return Err(bad("slide.selectRange", "no such slide"));
|
||||
}
|
||||
let (a, b) = (from.min(to), from.max(to));
|
||||
s.select(|doc, sel| {
|
||||
let range: Vec<SlideId> = doc.slides.iter().skip(a).take(b - a + 1).map(|x| x.id).collect();
|
||||
if add {
|
||||
for id in range {
|
||||
if !sel.slides.contains(&id) {
|
||||
sel.slides.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sel.slides = range;
|
||||
}
|
||||
sel.slide = to;
|
||||
sel.range_anchor = doc.slides.get(from).zip(doc.slides.get(to)).map(|(a, b)| (a.id, b.id));
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.target = Target::Slides;
|
||||
})?;
|
||||
Ok(json!({"selected": s.doc()?.selection.slides}))
|
||||
}
|
||||
@@ -667,6 +758,111 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A deck of `n` slides and its slide ids, in order.
|
||||
fn deck(n: usize) -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < n {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
let ids = order(&s);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<u32> {
|
||||
s.doc().expect("doc").doc.slides.iter().map(|x| x.id.0).collect()
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> (usize, Vec<u32>) {
|
||||
let sel = &s.doc().expect("doc").selection;
|
||||
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_from_to_moves_one_slide() {
|
||||
let (mut s, ids) = deck(4);
|
||||
s.execute("slide.move", &json!({"from": 3, "to": 0})).expect("move");
|
||||
assert_eq!(order(&s), [ids[3], ids[0], ids[1], ids[2]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_ids_lands_them_as_one_run_in_deck_order() {
|
||||
let (mut s, ids) = deck(6);
|
||||
// Listed backwards: they still land in the order they had.
|
||||
s.execute("slide.move", &json!({"ids": [ids[4], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(order(&s), [ids[1], ids[4], ids[0], ids[2], ids[3], ids[5]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[1], ids[4]]), "the moved slides stay selected");
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[2]], "to": 2})).expect("to the middle");
|
||||
assert_eq!(order(&s), [ids[1], ids[3], ids[0], ids[2], ids[4], ids[5]]);
|
||||
assert_eq!(selected(&s), (2, vec![ids[0], ids[2]]));
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[1], ids[3]], "to": 1000})).expect("past the end clamps");
|
||||
assert_eq!(order(&s), [ids[0], ids[2], ids[4], ids[5], ids[1], ids[3]]);
|
||||
assert_eq!(selected(&s), (4, vec![ids[1], ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_several_slides_is_one_undo_step() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("slide.selectSlides", &json!({"ids": [ids[0], ids[3]]})).expect("select");
|
||||
let before = selected(&s);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[3]], "to": 3})).expect("move");
|
||||
let after = order(&s);
|
||||
assert_eq!(after, [ids[1], ids[2], ids[4], ids[0], ids[3]]);
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(selected(&s), before);
|
||||
s.execute("edit.redo", &json!({})).expect("redo");
|
||||
assert_eq!(order(&s), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_slides_join_the_section_before_them() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("section.add", &json!({"name": "A", "at": 0})).expect("A");
|
||||
s.execute("section.add", &json!({"name": "B", "at": 3})).expect("B");
|
||||
// A = 0,1,2; B = 3,4. Move 0 and 1 to the end: B gets them, A keeps only slide 2.
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 3})).expect("move");
|
||||
let sections =
|
||||
|s: &Session| -> Vec<Vec<u32>> { s.doc().expect("doc").doc.sections.iter().map(|x| x.slides.iter().map(|id| id.0).collect()).collect() };
|
||||
assert_eq!(sections(&s), [vec![ids[2]], vec![ids[3], ids[4], ids[0], ids[1]]]);
|
||||
// Move the whole of A away: A stays, empty; the run at the front joins the first section.
|
||||
s.execute("slide.move", &json!({"ids": [ids[2]], "to": 4})).expect("empty A");
|
||||
assert_eq!(sections(&s), [vec![], vec![ids[3], ids[4], ids[0], ids[1], ids[2]]]);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(sections(&s), [vec![ids[0], ids[1]], vec![ids[3], ids[4], ids[2]]]);
|
||||
let all: Vec<u32> = sections(&s).concat();
|
||||
assert_eq!(all, order(&s), "sections follow slide order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ids_are_rejected_and_change_nothing() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let lots_of_unknown: Vec<u64> = (1_000_000..1_100_000).collect();
|
||||
for p in [
|
||||
json!({"ids": [], "to": 0}),
|
||||
json!({"ids": [ids[0], 999_999], "to": 0}),
|
||||
json!({"ids": [-1], "to": 0}),
|
||||
json!({"ids": ["1"], "to": 0}),
|
||||
json!({"ids": [1.5], "to": 0}),
|
||||
json!({"ids": [u64::MAX], "to": 0}),
|
||||
json!({"ids": ids[0], "to": 0}),
|
||||
json!({"ids": lots_of_unknown, "to": 0}),
|
||||
json!({"ids": [ids[1]]}),
|
||||
] {
|
||||
assert!(s.execute("slide.move", &p).is_err(), "slide.move must reject {p}");
|
||||
assert_eq!(order(&s), ids, "{p}");
|
||||
}
|
||||
// Repeats fold into one.
|
||||
let repeated = vec![ids[2]; 100_000];
|
||||
s.execute("slide.move", &json!({"ids": repeated, "to": 0})).expect("repeats");
|
||||
assert_eq!(order(&s), [ids[2], ids[0], ids[1]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[2]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -697,4 +893,106 @@ mod tests {
|
||||
let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")];
|
||||
assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
fn deck_of(n: usize) -> Session {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < n {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn selected_indices(s: &Session) -> Vec<usize> {
|
||||
let st = s.doc().expect("doc");
|
||||
let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_range_covers_every_slide_between() {
|
||||
let mut s = deck_of(6);
|
||||
s.execute("slide.go", &json!({"index": 1})).expect("go");
|
||||
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![1, 2, 3, 4]);
|
||||
assert_eq!(s.doc().expect("doc").selection.slide, 4);
|
||||
// Backwards works too, and replaces the earlier selection.
|
||||
s.execute("slide.selectRange", &json!({"from": 5, "to": 3})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_range_add_keeps_the_existing_selection() {
|
||||
let mut s = deck_of(8);
|
||||
s.execute("slide.selectSlides", &json!({"indices": [0, 1]})).expect("select");
|
||||
s.execute("slide.go", &json!({"index": 4})).expect("go");
|
||||
s.execute("slide.selectSlides", &json!({"indices": [4]})).expect("select");
|
||||
s.execute("slide.selectRange", &json!({"to": 6, "add": true})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
|
||||
s.execute("slide.selectSlides", &json!({"indices": [0]})).expect("select");
|
||||
s.execute("slide.selectRange", &json!({"from": 3, "to": 5, "add": true})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![0, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_range_rejects_out_of_range_slides() {
|
||||
let mut s = deck_of(3);
|
||||
assert!(s.execute("slide.selectRange", &json!({"to": 3})).is_err());
|
||||
assert!(s.execute("slide.selectRange", &json!({"from": 9, "to": 1})).is_err());
|
||||
assert!(s.execute("slide.selectRange", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_range_rejects_an_invalid_from_instead_of_using_the_current_slide() {
|
||||
let mut s = deck_of(4);
|
||||
s.execute("slide.go", &json!({"index": 2})).expect("go");
|
||||
assert!(s.execute("slide.selectRange", &json!({"from": -1, "to": 3})).is_err());
|
||||
assert!(s.execute("slide.selectRange", &json!({"from": "x", "to": 3})).is_err());
|
||||
assert_eq!(selected_indices(&s), vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_range_extends_from_the_original_anchor() {
|
||||
let mut s = deck_of(8);
|
||||
s.execute("slide.go", &json!({"index": 2})).expect("go");
|
||||
s.execute("slide.selectRange", &json!({"to": 5})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5]);
|
||||
// Shift-click further on: still anchored at 2, not at 5.
|
||||
s.execute("slide.selectRange", &json!({"to": 7})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5, 6, 7]);
|
||||
// Shift-click back across the anchor.
|
||||
s.execute("slide.selectRange", &json!({"to": 0})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![0, 1, 2]);
|
||||
// A plain click on another slide starts a new anchor.
|
||||
s.execute("slide.go", &json!({"index": 4})).expect("go");
|
||||
s.execute("slide.selectRange", &json!({"to": 6})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_slide_drops_the_range_anchor() {
|
||||
let mut s = deck_of(6);
|
||||
s.execute("slide.go", &json!({"index": 0})).expect("go");
|
||||
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
|
||||
// Slide 0 moves to index 2 and stays current; index 0 now holds another slide, so the
|
||||
// next Shift-click starts at the moved slide instead of at index 0.
|
||||
s.execute("slide.move", &json!({"from": 0, "to": 2})).expect("move");
|
||||
assert_eq!(s.doc().expect("doc").selection.slide, 2);
|
||||
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
|
||||
assert_eq!(selected_indices(&s), vec![2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_range_clears_a_table_cell_selection_and_returns_to_the_slides() {
|
||||
let mut s = deck_of(3);
|
||||
s.select(|_, sel| {
|
||||
sel.cells = Some((deckcraft_model::ShapeId(1), (0, 0), (1, 1)));
|
||||
sel.target = crate::Target::Master { master: 0 };
|
||||
})
|
||||
.expect("cells");
|
||||
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
|
||||
let sel = &s.doc().expect("doc").selection;
|
||||
assert!(sel.cells.is_none());
|
||||
assert_eq!(sel.target, crate::Target::Slides);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1775
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
use super::nodes::Item;
|
||||
use super::paint::{accent, deco, line_shape, paint, text_shape};
|
||||
use super::{ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, MAX_SHAPES, SchemeSlot, Xfrm};
|
||||
|
||||
/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on
|
||||
/// a row of their own between the parent and its other children. `half_circle` draws each node as
|
||||
/// its text in an open half ring (Half Circle Organization Chart).
|
||||
pub(super) fn org_chart(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
let half_circle = d.kind == "halfCircleOrgChart";
|
||||
let name_title = d.kind == "nameTitleOrgChart";
|
||||
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
|
||||
if nodes.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut parent: Vec<Option<usize>> = vec![None; nodes.len()];
|
||||
let mut stack: Vec<usize> = vec![];
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
while stack.last().is_some_and(|&p| nodes[p].level >= n.level) {
|
||||
stack.pop();
|
||||
}
|
||||
parent[i] = stack.last().copied();
|
||||
stack.push(i);
|
||||
}
|
||||
let asst = |i: usize| nodes[i].assistant && parent[i].is_some();
|
||||
let kids = |i: Option<usize>| -> Vec<usize> { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() };
|
||||
let mut leaves = vec![0usize; nodes.len()];
|
||||
for i in (0..nodes.len()).rev() {
|
||||
let k = kids(Some(i));
|
||||
leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() };
|
||||
}
|
||||
// Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent.
|
||||
let mut slot = vec![0usize; nodes.len()];
|
||||
for i in 0..nodes.len() {
|
||||
slot[i] = match parent[i] {
|
||||
None => 0,
|
||||
Some(p) if asst(i) => slot[p] + 1,
|
||||
Some(p) => slot[p] + 2,
|
||||
};
|
||||
}
|
||||
let mut used: Vec<usize> = slot.clone();
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0);
|
||||
let rows = used.len().max(1);
|
||||
let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::<usize>().max(1);
|
||||
let cell_w = area.w / total as f64;
|
||||
let row_h = area.h / rows as f64;
|
||||
let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62);
|
||||
let mut left = vec![0usize; nodes.len()];
|
||||
let mut next_root = 0;
|
||||
for i in 0..nodes.len() {
|
||||
if asst(i) {
|
||||
continue;
|
||||
}
|
||||
let mut start = match parent[i] {
|
||||
None => {
|
||||
let s = next_root;
|
||||
next_root += leaves[i];
|
||||
s
|
||||
}
|
||||
Some(p) => left[p],
|
||||
};
|
||||
if let Some(p) = parent[i] {
|
||||
for s in kids(Some(p)) {
|
||||
if s == i {
|
||||
break;
|
||||
}
|
||||
start += leaves[s];
|
||||
}
|
||||
}
|
||||
left[i] = start;
|
||||
}
|
||||
let mut side = vec![0usize; nodes.len()];
|
||||
let boxes: Vec<Xfrm> = (0..nodes.len())
|
||||
.map(|i| {
|
||||
let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0;
|
||||
match parent[i] {
|
||||
Some(p) if asst(i) => {
|
||||
// Assistants alternate right and left of the parent's trunk.
|
||||
let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count();
|
||||
side[i] = k;
|
||||
let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w;
|
||||
let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1;
|
||||
let x = if k % 2 == 0 { pc + off } else { pc - off - bw };
|
||||
Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh)
|
||||
}
|
||||
_ => {
|
||||
let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w;
|
||||
Xfrm::new(cx - bw / 2.0, y, bw, bh)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<Item> = vec![];
|
||||
for i in 0..nodes.len() {
|
||||
let Some(p) = parent[i] else { continue };
|
||||
let (a, b) = (boxes[p], boxes[i]);
|
||||
let pc = a.x + a.w / 2.0;
|
||||
if asst(i) {
|
||||
let my = b.y + b.h / 2.0;
|
||||
out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![]));
|
||||
let ex = if b.x > pc { b.x } else { b.x + b.w };
|
||||
out.push((line_shape((pc, my), (ex, my)), vec![]));
|
||||
} else {
|
||||
let mid = b.y - (row_h - bh) / 2.0;
|
||||
let cc = b.x + b.w / 2.0;
|
||||
out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![]));
|
||||
out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![]));
|
||||
out.push((line_shape((cc, mid), (cc, b.y)), vec![]));
|
||||
}
|
||||
}
|
||||
let _ = side;
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let depth = slot[i] / 2;
|
||||
if half_circle {
|
||||
// Two half rings round the text: the upper left one in the accent, the lower right one
|
||||
// in its tint.
|
||||
let x = boxes[i];
|
||||
for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() {
|
||||
let mut arc = deco("blockArc", x, 0.0);
|
||||
arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] };
|
||||
paint(d, &mut arc, depth, false);
|
||||
if k == 1 {
|
||||
let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000));
|
||||
arc.fill = Some(Fill::solid(c));
|
||||
}
|
||||
arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
arc.effects = None;
|
||||
out.push((arc, vec![]));
|
||||
}
|
||||
let mut t = text_shape("rect", x, &[n], true);
|
||||
paint(d, &mut t, depth, true);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
out.push((t, vec![i]));
|
||||
} else {
|
||||
let mut x = boxes[i];
|
||||
if name_title {
|
||||
// The name in the upper part; the title box overlaps the lower edge.
|
||||
x.h *= 0.85;
|
||||
}
|
||||
let mut sh = text_shape("rect", x, &[n], true);
|
||||
paint(d, &mut sh, depth, false);
|
||||
out.push((sh, vec![i]));
|
||||
if name_title {
|
||||
let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42);
|
||||
let title = DiagramNode { text: n.title.clone(), ..Default::default() };
|
||||
let mut t = text_shape("rect", tb, &[&title], true);
|
||||
paint(d, &mut t, depth, true);
|
||||
t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1)));
|
||||
if let Some(b) = &mut t.text {
|
||||
for p in &mut b.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
r.props.size = Some(11.0);
|
||||
}
|
||||
p.end_props.size = Some(11.0);
|
||||
}
|
||||
}
|
||||
out.push((t, vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves.
|
||||
pub(super) fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec<Item> {
|
||||
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
|
||||
if nodes.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut parent: Vec<Option<usize>> = vec![None; nodes.len()];
|
||||
let mut stack: Vec<usize> = vec![];
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
while stack.last().is_some_and(|&p| nodes[p].level >= n.level) {
|
||||
stack.pop();
|
||||
}
|
||||
parent[i] = stack.last().copied();
|
||||
stack.push(i);
|
||||
}
|
||||
let kids = |i: Option<usize>| -> Vec<usize> { (0..nodes.len()).filter(|&k| parent[k] == i).collect() };
|
||||
let mut leaves = vec![0usize; nodes.len()];
|
||||
for i in (0..nodes.len()).rev() {
|
||||
let k = kids(Some(i));
|
||||
leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() };
|
||||
}
|
||||
let depth: Vec<usize> = (0..nodes.len())
|
||||
.map(|i| {
|
||||
let (mut d, mut p) = (0, parent[i]);
|
||||
while let Some(q) = p {
|
||||
d += 1;
|
||||
p = parent[q];
|
||||
}
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
let rows = depth.iter().max().copied().unwrap_or(0) + 1;
|
||||
let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::<usize>().max(1);
|
||||
// Horizontal Hierarchy: the same tree turned a quarter, levels from left to right.
|
||||
let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) };
|
||||
let cell_w = span / total as f64;
|
||||
let row_h = across / rows as f64;
|
||||
let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) };
|
||||
// Left edge (in leaves) of each node's span.
|
||||
let mut left = vec![0usize; nodes.len()];
|
||||
let mut next_root = 0;
|
||||
for i in 0..nodes.len() {
|
||||
let mut start = match parent[i] {
|
||||
None => {
|
||||
let s = next_root;
|
||||
next_root += leaves[i];
|
||||
s
|
||||
}
|
||||
Some(p) => left[p],
|
||||
};
|
||||
if let Some(p) = parent[i] {
|
||||
for s in kids(Some(p)) {
|
||||
if s == i {
|
||||
break;
|
||||
}
|
||||
start += leaves[s];
|
||||
}
|
||||
}
|
||||
left[i] = start;
|
||||
}
|
||||
let boxes: Vec<Xfrm> = (0..nodes.len())
|
||||
.map(|i| {
|
||||
let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w;
|
||||
let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0;
|
||||
if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) }
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<Item> = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut sh = text_shape("roundRect", boxes[i], &[n], true);
|
||||
paint(d, &mut sh, depth[i], false);
|
||||
(sh, vec![i])
|
||||
})
|
||||
.collect();
|
||||
for i in 0..nodes.len() {
|
||||
if let Some(p) = parent[i] {
|
||||
let (a, b) = (boxes[p], boxes[i]);
|
||||
let (p, q) = if horizontal {
|
||||
((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0))
|
||||
} else {
|
||||
((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y))
|
||||
};
|
||||
out.push((line_shape(p, q), vec![]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove, sync_text};
|
||||
use super::paint::layout;
|
||||
use super::{
|
||||
AutoFit, COLORS, Diagram, DiagramNode, EngineError, Geom, MAX_LEVEL, NO_ID_SPACE, Paragraph, Presentation, Result, STYLES, Session, Shape,
|
||||
ShapeId, ShapeKind, TextBody, Value, Xfrm, bad, bool_or, current_shapes, is_kind, json, shape_of, str_param, targets, xfrm_of,
|
||||
};
|
||||
|
||||
/// The SmartArt group that `id` is, or is inside.
|
||||
fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> {
|
||||
for sh in shapes {
|
||||
if sh.id == id && sh.diagram.is_some() {
|
||||
return Some(sh);
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind {
|
||||
if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() {
|
||||
return Some(sh);
|
||||
}
|
||||
if let Some(g) = diagram_group(children, id) {
|
||||
return Some(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it.
|
||||
pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> {
|
||||
let shapes = current_shapes(&st.doc, &st.selection)?;
|
||||
let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?;
|
||||
let mut d = g.diagram.clone()?;
|
||||
if let ShapeKind::Group { children, .. } = &g.kind {
|
||||
sync_text(&mut d, children);
|
||||
}
|
||||
Some((g.id, d))
|
||||
}
|
||||
|
||||
fn target_group(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let ids = targets(s, p)?;
|
||||
ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic"))
|
||||
}
|
||||
|
||||
/// Children with fresh ids for diagram `d` inside a group whose child space is `area`.
|
||||
fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result<Vec<Shape>> {
|
||||
let mut v = layout(d, area);
|
||||
for sh in &mut v {
|
||||
sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group.
|
||||
fn edit_diagram<T>(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result<T>) -> Result<T> {
|
||||
let gid = target_group(s, p, cmd)?;
|
||||
s.edit(|doc, sel| {
|
||||
let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?;
|
||||
let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) };
|
||||
sync_text(&mut d, &children);
|
||||
let out = f(&mut d)?;
|
||||
normalize(&mut d.nodes);
|
||||
d.imported = false;
|
||||
let kids = regenerate(cmd, doc, &d, child)?;
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?;
|
||||
sh.kind = ShapeKind::Group { children: kids, child };
|
||||
sh.diagram = Some(d);
|
||||
sel.shapes = vec![gid];
|
||||
sel.text = None;
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn index(p: &Value, cmd: &str) -> Result<usize> {
|
||||
p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required"))
|
||||
}
|
||||
|
||||
/// Insert a new SmartArt graphic (`insert.smartArt`).
|
||||
pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Result<Value> {
|
||||
let kind = if is_kind(kind) { kind } else { "process" };
|
||||
if nodes.is_empty() {
|
||||
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect();
|
||||
}
|
||||
normalize(&mut nodes);
|
||||
let d = Diagram { kind: kind.into(), nodes, ..Default::default() };
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6);
|
||||
let id = s.edit(|doc, sel| {
|
||||
let children = regenerate("insert.smartArt", doc, &d, area)?;
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?);
|
||||
let g = Shape {
|
||||
id,
|
||||
name: format!("SmartArt ({kind})"),
|
||||
xfrm: Some(area),
|
||||
kind: ShapeKind::Group { children, child: area },
|
||||
diagram: Some(d),
|
||||
..Default::default()
|
||||
};
|
||||
crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g);
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
pub(super) fn nodes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = target_group(s, p, "smartArt.nodes")?;
|
||||
let g = shape_of(s, gid)?;
|
||||
let mut d = g.diagram.unwrap_or_default();
|
||||
if let ShapeKind::Group { children, .. } = &g.kind {
|
||||
sync_text(&mut d, children);
|
||||
}
|
||||
Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes}))
|
||||
}
|
||||
|
||||
pub(super) fn set_nodes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.setNodes";
|
||||
let given: Option<Vec<DiagramNode>> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) {
|
||||
(Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?),
|
||||
(None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))),
|
||||
_ => None,
|
||||
};
|
||||
let one = p.get("index").map(|_| index(p, CMD)).transpose()?;
|
||||
let text = str_param(p, "text").map(String::from);
|
||||
let title = str_param(p, "title").map(String::from);
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if let Some(n) = given {
|
||||
d.nodes = n;
|
||||
}
|
||||
if let (Some(i), Some(t)) = (one, text) {
|
||||
d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t;
|
||||
}
|
||||
if let (Some(i), Some(t)) = (one, title) {
|
||||
d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn add_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
|
||||
let pos = str_param(p, "position").unwrap_or("after").to_string();
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
edit_diagram(s, p, "smartArt.addNode", |d| {
|
||||
let at = add(&mut d.nodes, i, &pos, &text);
|
||||
Ok(json!({"index": at}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn remove_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.removeNode";
|
||||
let i = index(p, CMD)?;
|
||||
edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) })
|
||||
}
|
||||
|
||||
pub(super) fn promote(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.promote";
|
||||
let i = index(p, CMD)?;
|
||||
edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) })
|
||||
}
|
||||
|
||||
pub(super) fn demote(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.demote";
|
||||
let i = index(p, CMD)?;
|
||||
edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) })
|
||||
}
|
||||
|
||||
/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in
|
||||
/// the shape's place.
|
||||
pub(super) fn from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.fromText";
|
||||
let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string();
|
||||
let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?;
|
||||
let (area, nodes) = {
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?;
|
||||
let body =
|
||||
sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?;
|
||||
let mut nodes: Vec<DiagramNode> = body
|
||||
.paragraphs
|
||||
.iter()
|
||||
.filter(|p| !p.text().trim().is_empty())
|
||||
.map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() })
|
||||
.collect();
|
||||
normalize(&mut nodes);
|
||||
// A small text box grows to a size a diagram can use, keeping its top left on the slide.
|
||||
let mut a = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let size = st.doc.slide_size;
|
||||
a.w = a.w.max(size.width * 0.5).min(size.width);
|
||||
a.h = a.h.max(size.height * 0.35).min(size.height);
|
||||
a.x = a.x.min(size.width - a.w).max(0.0);
|
||||
a.y = a.y.min(size.height - a.h).max(0.0);
|
||||
a.rot = 0.0;
|
||||
(a, nodes)
|
||||
};
|
||||
let d = Diagram { kind: kind.clone(), nodes, ..Default::default() };
|
||||
let gid = s.edit(|doc, sel| {
|
||||
let children = regenerate(CMD, doc, &d, area)?;
|
||||
let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
|
||||
let g = Shape {
|
||||
id: gid,
|
||||
name: format!("SmartArt ({kind})"),
|
||||
xfrm: Some(area),
|
||||
kind: ShapeKind::Group { children, child: area },
|
||||
diagram: Some(d),
|
||||
..Default::default()
|
||||
};
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?;
|
||||
// In the text shape's place in the z-order.
|
||||
shapes[at] = g;
|
||||
sel.shapes = vec![gid];
|
||||
sel.text = None;
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({"id": gid.0}))
|
||||
}
|
||||
|
||||
/// Convert to Text: a text box with the outline, one bulleted paragraph per node.
|
||||
pub(super) fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.toText";
|
||||
let gid = target_group(s, p, CMD)?;
|
||||
let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
|
||||
// The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included).
|
||||
let (title_pt, bullet_pt) = {
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
|
||||
text_sizes(g.children())
|
||||
};
|
||||
let new = s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?;
|
||||
let area = shapes[at].xfrm.unwrap_or_default();
|
||||
let mut body = TextBody::default();
|
||||
for n in &d.nodes {
|
||||
let mut para = Paragraph::new(&n.text);
|
||||
para.level = n.level.min(MAX_LEVEL);
|
||||
let size = if n.level == 0 { title_pt } else { bullet_pt };
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(size);
|
||||
}
|
||||
para.end_props.size = Some(size);
|
||||
para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() });
|
||||
para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0));
|
||||
para.props.indent = Some(-14.0);
|
||||
body.paragraphs.push(para);
|
||||
}
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Paragraph::new(""));
|
||||
}
|
||||
body.body.wrap = Some(true);
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
|
||||
let tb =
|
||||
Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() };
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
shapes[at] = tb;
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"id": new.0}))
|
||||
}
|
||||
|
||||
/// Convert to Shapes: keep the drawing as an ordinary group.
|
||||
pub(super) fn to_shapes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.toShapes";
|
||||
let gid = target_group(s, p, CMD)?;
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?;
|
||||
g.diagram = None;
|
||||
g.name = "Group".into();
|
||||
sel.shapes = vec![gid];
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none).
|
||||
fn text_sizes(children: &[Shape]) -> (f64, f64) {
|
||||
let mut titles: Vec<f64> = vec![];
|
||||
let mut bullets: Vec<f64> = vec![];
|
||||
for c in children {
|
||||
let Some(b) = &c.text else { continue };
|
||||
for p in &b.paragraphs {
|
||||
let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue };
|
||||
if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) }
|
||||
}
|
||||
}
|
||||
let mode = |v: &mut Vec<f64>, d: f64| {
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
let mut best = (d, 0usize);
|
||||
let mut k = 0;
|
||||
while k < v.len() {
|
||||
let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count();
|
||||
if j > best.1 {
|
||||
best = (v[k], j);
|
||||
}
|
||||
k += j;
|
||||
}
|
||||
best.0
|
||||
};
|
||||
(mode(&mut titles, 20.0), mode(&mut bullets, 14.0))
|
||||
}
|
||||
|
||||
/// The graphic `gid` with its outline (typed text synced).
|
||||
fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> {
|
||||
let st = s.doc().ok()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection)?;
|
||||
let g = diagram_group(shapes, gid)?;
|
||||
let mut d = g.diagram.clone()?;
|
||||
if let ShapeKind::Group { children, .. } = &g.kind {
|
||||
sync_text(&mut d, children);
|
||||
}
|
||||
Some((g.id, d))
|
||||
}
|
||||
|
||||
pub(super) fn set_picture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.picture";
|
||||
let i = index(p, CMD)?;
|
||||
let media = if bool_or(p, "reset", false) {
|
||||
None
|
||||
} else {
|
||||
let (name, bytes) = crate::cmd::insert::media_bytes(p, CMD)?;
|
||||
let ct = crate::cmd::insert::content_type(&name, &bytes);
|
||||
target_group(s, p, CMD)?;
|
||||
Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?)
|
||||
};
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
// A sub-point's picture is its top-level node's: that's the node the placeholder shows.
|
||||
let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i);
|
||||
let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?;
|
||||
n.picture = media;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn change_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.style";
|
||||
let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string();
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if d.style != v {
|
||||
d.style_xml = None;
|
||||
}
|
||||
d.style = v;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn change_colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.colors";
|
||||
let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string();
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if d.colors != v {
|
||||
d.colors_xml = None;
|
||||
}
|
||||
d.colors = v;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.layout";
|
||||
let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string();
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if d.kind != kind {
|
||||
// PowerPoint's own layout no longer applies.
|
||||
d.layout_id.clear();
|
||||
d.layout_xml = None;
|
||||
}
|
||||
d.kind = kind;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
use super::diagrams::{hierarchy, org_chart};
|
||||
use super::nodes::{Item, groups};
|
||||
use super::paint::{accent, deco, line_shape, paint, picture_shape, text_shape};
|
||||
use super::{Align, ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, Xfrm};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct L<'a> {
|
||||
d: &'a Diagram,
|
||||
area: Xfrm,
|
||||
gs: &'a [Vec<usize>],
|
||||
n: usize,
|
||||
nf: f64,
|
||||
}
|
||||
|
||||
impl L<'_> {
|
||||
fn nodes(&self, g: &[usize]) -> Vec<&DiagramNode> {
|
||||
g.iter().filter_map(|i| self.d.nodes.get(*i)).collect()
|
||||
}
|
||||
|
||||
/// A titled shape for top-level group `i`, painted in its accent.
|
||||
fn titled(&self, out: &mut Vec<Item>, preset: &str, x: Xfrm, i: usize, g: &[usize]) {
|
||||
let mut sh = text_shape(preset, x, &self.nodes(g), true);
|
||||
paint(self.d, &mut sh, i, false);
|
||||
out.push((sh, g.to_vec()));
|
||||
}
|
||||
|
||||
fn arrow(&self, out: &mut Vec<Item>, x: Xfrm, rot: f64, i: usize) {
|
||||
let mut sh = deco("rightArrow", x, rot);
|
||||
paint(self.d, &mut sh, i, true);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
let gs = groups(d);
|
||||
let n = gs.len().max(1);
|
||||
let l = L { d, area, gs: &gs, n, nf: n as f64 };
|
||||
let mut out: Vec<Item> = vec![];
|
||||
match d.kind.as_str() {
|
||||
"hierarchy" => return hierarchy(d, area, false),
|
||||
"horizontalHierarchy" => return hierarchy(d, area, true),
|
||||
"orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area),
|
||||
"list" | "hBulletList" => arm_list(&l, &mut out),
|
||||
"blockList" => arm_block_list(&l, &mut out),
|
||||
"process" | "chevron" => arm_process(&l, &mut out),
|
||||
"verticalProcess" => arm_vertical_process(&l, &mut out),
|
||||
"stepUp" => arm_step_up(&l, &mut out),
|
||||
"cycle" | "blockCycle" => arm_cycle(&l, &mut out),
|
||||
"radial" => arm_radial(&l, &mut out),
|
||||
"pyramid" | "invertedPyramid" => arm_pyramid(&l, &mut out),
|
||||
"matrix" | "titledMatrix" => arm_matrix(&l, &mut out),
|
||||
"pictureList" => arm_picture_list(&l, &mut out),
|
||||
"pictureCaption" | "pictureProcess" => arm_picture_caption(&l, &mut out),
|
||||
"textCycle" | "continuousCycle" => arm_text_cycle(&l, &mut out),
|
||||
"segmentedCycle" | "pie" => arm_segmented_cycle(&l, &mut out),
|
||||
"multidirectionalCycle" => arm_multidirectional_cycle(&l, &mut out),
|
||||
"cycleMatrix" => arm_cycle_matrix(&l, &mut out),
|
||||
"segmentedPyramid" => arm_segmented_pyramid(&l, &mut out),
|
||||
"verticalBoxList" => arm_vertical_box_list(&l, &mut out),
|
||||
"trapezoidList" | "tableList" => arm_trapezoid_list(&l, &mut out),
|
||||
"accentProcess" => arm_accent_process(&l, &mut out),
|
||||
"continuousBlockProcess" => arm_continuous_block_process(&l, &mut out),
|
||||
"bendingProcess" => arm_bending_process(&l, &mut out),
|
||||
"linearVenn" | "stackedVenn" => arm_linear_venn(&l, &mut out),
|
||||
"nestedTarget" => arm_nested_target(&l, &mut out),
|
||||
"gridMatrix" => arm_grid_matrix(&l, &mut out),
|
||||
"gear" => arm_gear(&l, &mut out),
|
||||
"funnel" => arm_funnel(&l, &mut out),
|
||||
"pictureHList" => arm_picture_h_list(&l, &mut out),
|
||||
"timeline" => arm_timeline(&l, &mut out),
|
||||
"venn" => arm_venn(&l, &mut out),
|
||||
"target" => arm_target(&l, &mut out),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn arm_list(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below.
|
||||
let vertical = d.kind == "list";
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (cell, bar_h) = if vertical {
|
||||
(Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), (area.h / nf * 0.92).min(area.h * 0.12).max(18.0))
|
||||
} else {
|
||||
let w = area.w / nf;
|
||||
(Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22)
|
||||
};
|
||||
let has_sub = g.len() > 1;
|
||||
let bar_h = bar_h.min(cell.h);
|
||||
l.titled(out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]);
|
||||
if has_sub {
|
||||
let pad = cell.w * 0.03;
|
||||
let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0));
|
||||
let mut sh = text_shape("rect", x, &l.nodes(&g[1..]), false);
|
||||
paint(d, &mut sh, i, true);
|
||||
out.push((sh, g[1..].to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_block_list(l: &L, out: &mut Vec<Item>) {
|
||||
let L { area, gs, n, nf, .. } = *l;
|
||||
// Basic Block List: equal blocks in rows, as square as the area allows.
|
||||
let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n);
|
||||
let rows = n.div_ceil(cols);
|
||||
let (w, h) = (area.w / cols as f64, area.h / rows as f64);
|
||||
let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6));
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (c, r) = (i % cols, i / cols);
|
||||
let in_row = if r + 1 == rows { n - r * cols } else { cols };
|
||||
let x0 = area.x + (area.w - in_row as f64 * w) / 2.0;
|
||||
l.titled(out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_process(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
let chevron = d.kind == "chevron";
|
||||
let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 };
|
||||
let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0);
|
||||
let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 });
|
||||
let y = area.y + (area.h - h) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let x = area.x + i as f64 * (w + gap);
|
||||
l.titled(out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g);
|
||||
if !chevron && i + 1 < n {
|
||||
let aw = gap * 0.7;
|
||||
l.arrow(out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_vertical_process(l: &L, out: &mut Vec<Item>) {
|
||||
let L { area, gs, n, nf, .. } = *l;
|
||||
let gap = area.h * 0.06;
|
||||
let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0);
|
||||
let w = area.w * 0.6;
|
||||
let x = area.x + (area.w - w) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let y = area.y + i as f64 * (h + gap);
|
||||
l.titled(out, "roundRect", Xfrm::new(x, y, w, h), i, g);
|
||||
if i + 1 < n {
|
||||
let s = gap * 0.8;
|
||||
// A right arrow turned to point down.
|
||||
l.arrow(out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_step_up(l: &L, out: &mut Vec<Item>) {
|
||||
let L { area, gs, nf, .. } = *l;
|
||||
// Step Up Process: each step higher than the one before.
|
||||
let w = area.w / nf;
|
||||
let h = area.h / (nf + 1.0) * 1.6;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0);
|
||||
l.titled(out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_cycle(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
let block = d.kind == "blockCycle";
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62);
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
|
||||
let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) };
|
||||
l.titled(out, if block { "roundRect" } else { "ellipse" }, Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), i, g);
|
||||
if n > 1 {
|
||||
// An arrow halfway to the next node, along the circle.
|
||||
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
|
||||
let s = (dd * 0.35).max(6.0);
|
||||
let rot = (i as f64 + 0.5) / nf * 360.0;
|
||||
l.arrow(out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_radial(l: &L, out: &mut Vec<Item>) {
|
||||
let L { area, gs, .. } = *l;
|
||||
// Basic Radial: the first node in the middle, the others around it with spokes.
|
||||
let Some((centre, rest)) = gs.split_first() else { return };
|
||||
let m = rest.len().max(1) as f64;
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dc = r * 0.75;
|
||||
let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7);
|
||||
for (k, _) in rest.iter().enumerate() {
|
||||
let a = (k as f64 / m * 360.0 - 90.0).to_radians();
|
||||
let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin());
|
||||
out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![]));
|
||||
}
|
||||
l.titled(out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre);
|
||||
for (k, g) in rest.iter().enumerate() {
|
||||
let a = (k as f64 / m * 360.0 - 90.0).to_radians();
|
||||
l.titled(out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_pyramid(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as
|
||||
// its own polygon so the sides keep the triangle's slope and the text stays upright.
|
||||
let inverted = d.kind == "invertedPyramid";
|
||||
let h = area.h / nf;
|
||||
let pw = area.h.min(area.w) * 1.15;
|
||||
let px = area.x + (area.w - pw) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf);
|
||||
let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) };
|
||||
let (wt, wb) = (pw * t, pw * bt);
|
||||
let w = wt.max(wb).max(1.0);
|
||||
let bh = h * 0.96;
|
||||
let x = px + (pw - w) / 2.0;
|
||||
let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0);
|
||||
let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib);
|
||||
let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &l.nodes(g), true);
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }],
|
||||
};
|
||||
// Keep the text inside the sloped sides: inset it to a bit past the band's mid width.
|
||||
// The tip band is a triangle: its text sits at the wide end, inset to the width there.
|
||||
let tip = wt.min(wb) < 1e-6;
|
||||
let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 };
|
||||
if let Some(b) = &mut sh.text {
|
||||
b.body.inset_l = Some(inset);
|
||||
b.body.inset_r = Some(inset);
|
||||
if tip {
|
||||
b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom });
|
||||
}
|
||||
}
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_matrix(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, .. } = *l;
|
||||
let titled_m = d.kind == "titledMatrix";
|
||||
let s = area.h.min(area.w);
|
||||
let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0);
|
||||
let (quads, title): (&[Vec<usize>], Option<&Vec<usize>>) =
|
||||
if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (gs, None) };
|
||||
for (i, g) in quads.iter().take(4).enumerate() {
|
||||
let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0);
|
||||
l.titled(out, "roundRect", x, i + usize::from(titled_m), g);
|
||||
}
|
||||
if let Some(t) = title {
|
||||
let c = s * 0.42;
|
||||
let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &l.nodes(t), true);
|
||||
paint(d, &mut sh, 0, true);
|
||||
out.push((sh, t.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_picture_list(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Vertical Picture List: a picture square at the left of each row, the text beside it.
|
||||
let h = area.h / nf;
|
||||
let bh = h * 0.88;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let y = area.y + i as f64 * h;
|
||||
let s = bh.min(area.w * 0.3);
|
||||
out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![]));
|
||||
l.titled(out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_picture_caption(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one
|
||||
// row, arrows between the steps.
|
||||
let process = d.kind == "pictureProcess";
|
||||
let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) };
|
||||
let rows = n.div_ceil(cols);
|
||||
let gap = if process { area.w * 0.05 } else { 0.0 };
|
||||
let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64;
|
||||
let h = area.h / rows as f64;
|
||||
let bw = if process { w } else { w * 0.9 };
|
||||
let ch = (h * 0.9).min(bw * 1.1);
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (c, r) = (i % cols, i / cols);
|
||||
let in_row = if r + 1 == rows { n - r * cols } else { cols };
|
||||
let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0;
|
||||
let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0;
|
||||
let y = area.y + r as f64 * h + (h - ch) / 2.0;
|
||||
let ph = ch * 0.62;
|
||||
out.push((
|
||||
picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]),
|
||||
vec![],
|
||||
));
|
||||
l.titled(out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g);
|
||||
if process && i + 1 < n {
|
||||
let a = (gap * 0.7).min(ch * 0.2);
|
||||
l.arrow(out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_text_cycle(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on
|
||||
// a ring.
|
||||
let text_only = d.kind == "textCycle";
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62);
|
||||
if !text_only {
|
||||
let ring = r * 2.0 + dd * 0.3;
|
||||
let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0);
|
||||
sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] };
|
||||
paint(d, &mut sh, 0, true);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
|
||||
let (w, h) = (dd * 1.35, dd * 0.7);
|
||||
let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &l.nodes(g), true);
|
||||
paint(d, &mut sh, i, false);
|
||||
if text_only {
|
||||
sh.fill = Some(Fill::None);
|
||||
sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
sh.effects = None;
|
||||
let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000));
|
||||
if let Some(b) = &mut sh.text {
|
||||
for p in &mut b.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
r.props.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if n > 1 {
|
||||
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
|
||||
let s = (dd * 0.3).max(6.0);
|
||||
l.arrow(out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), (i as f64 + 0.5) / nf * 360.0, i);
|
||||
}
|
||||
}
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_segmented_cycle(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges.
|
||||
// The text sits in a clear box over each segment's middle.
|
||||
let ring = d.kind == "segmentedCycle";
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let gap = if ring { 2.0 } else { 0.0 };
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
// Angles clockwise from 3 o'clock, starting at the top.
|
||||
let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0;
|
||||
let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0;
|
||||
let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0);
|
||||
let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round();
|
||||
// blockArc runs from its first angle to its second clockwise; so does pie.
|
||||
sh.geom = Geom::Preset {
|
||||
name: if ring { "blockArc" } else { "pie" }.into(),
|
||||
adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] },
|
||||
};
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
let m = ((a0 + a1) / 2.0).to_radians();
|
||||
let rr = if ring { s * 0.4375 } else { s * 0.3 };
|
||||
let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9);
|
||||
let th = s * 0.16;
|
||||
let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &l.nodes(g), true);
|
||||
paint(d, &mut t, i, false);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
t.effects = None;
|
||||
out.push((t, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_multidirectional_cycle(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Multidirectional Cycle: nodes around a circle, double-headed arrows between them.
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55);
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
|
||||
l.titled(out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g);
|
||||
if n > 1 {
|
||||
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
|
||||
let s = (dd * 0.5).max(8.0);
|
||||
let mut sh =
|
||||
deco("leftRightArrow", Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), (i as f64 + 0.5) / nf * 360.0);
|
||||
paint(d, &mut sh, i, true);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_cycle_matrix(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, .. } = *l;
|
||||
// Cycle Matrix: four quarters of a circle in the middle with the top-level text, each
|
||||
// quarter's sub-points in a box at its corner.
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let c = s * 0.56;
|
||||
for (i, g) in gs.iter().take(4).enumerate() {
|
||||
let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]);
|
||||
let b = s * 0.4;
|
||||
let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b);
|
||||
if g.len() > 1 {
|
||||
let mut sh = text_shape("roundRect", bx, &l.nodes(&g[1..]), false);
|
||||
paint(d, &mut sh, i, true);
|
||||
if let Some(body) = &mut sh.text {
|
||||
// Text in the box's outer part, clear of the circle.
|
||||
body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 });
|
||||
body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 });
|
||||
body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom });
|
||||
}
|
||||
out.push((sh, g[1..].to_vec()));
|
||||
}
|
||||
}
|
||||
for (i, g) in gs.iter().take(4).enumerate() {
|
||||
let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]);
|
||||
let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5;
|
||||
let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0);
|
||||
sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] };
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
let q = c * 0.3;
|
||||
let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &l.nodes(&g[..1]), true);
|
||||
paint(d, &mut t, i, false);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
t.effects = None;
|
||||
out.push((t, g[..1].to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_segmented_pyramid(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node,
|
||||
// every other one upside down.
|
||||
let rows = (nf.sqrt().ceil() as usize).max(1);
|
||||
let s = area.h.min(area.w / 1.15);
|
||||
let pw = s * 1.15;
|
||||
let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0);
|
||||
let (tw, th) = (pw / rows as f64, s / rows as f64);
|
||||
let mut k = 0;
|
||||
'rows: for r in 0..rows {
|
||||
let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0;
|
||||
for c in 0..(2 * r + 1) {
|
||||
let Some(g) = gs.get(k) else { break 'rows };
|
||||
let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th);
|
||||
let mut sh = text_shape("rect", x, &l.nodes(g), true);
|
||||
// Drawn as a polygon (not a flipped preset) so upside-down text stays upright.
|
||||
let path = if c % 2 == 1 {
|
||||
format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0)
|
||||
} else {
|
||||
format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0)
|
||||
};
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath { w: tw, h: th, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }],
|
||||
};
|
||||
if let Some(b) = &mut sh.text {
|
||||
// The text in the triangle's wide half.
|
||||
b.body.inset_l = Some(tw * 0.22);
|
||||
b.body.inset_r = Some(tw * 0.22);
|
||||
if c % 2 == 1 {
|
||||
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
b.body.inset_b = Some(th * 0.35);
|
||||
} else {
|
||||
b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom);
|
||||
b.body.inset_t = Some(th * 0.35);
|
||||
}
|
||||
}
|
||||
paint(d, &mut sh, k, false);
|
||||
out.push((sh, g.clone()));
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_vertical_box_list(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Vertical Box List: a pale box per node with its sub-points, its title on a tab
|
||||
// overlapping the box's top left.
|
||||
let h = area.h / nf;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let y = area.y + i as f64 * h;
|
||||
let tab_h = (h * 0.3).min(40.0);
|
||||
let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &l.nodes(&g[1..]), false);
|
||||
paint(d, &mut bx, i, true);
|
||||
if let Some(b) = &mut bx.text {
|
||||
b.body.inset_t = Some(tab_h * 0.6);
|
||||
}
|
||||
out.push((bx, g[1..].to_vec()));
|
||||
l.titled(out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_trapezoid_list(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, .. } = *l;
|
||||
// Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the
|
||||
// first node as a header bar over a column per other node.
|
||||
let table = d.kind == "tableList";
|
||||
let (head, cols): (Option<&Vec<usize>>, &[Vec<usize>]) =
|
||||
if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, gs) };
|
||||
let top = if head.is_some() { area.h * 0.28 } else { 0.0 };
|
||||
if let Some(h) = head {
|
||||
l.titled(out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h);
|
||||
}
|
||||
let m = cols.len().max(1) as f64;
|
||||
let w = area.w / m;
|
||||
for (k, g) in cols.iter().enumerate() {
|
||||
let i = k + usize::from(table);
|
||||
let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top);
|
||||
let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &l.nodes(g), true);
|
||||
if let Some(b) = &mut sh.text {
|
||||
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
if !table {
|
||||
b.body.inset_l = Some(x.w * 0.2);
|
||||
b.body.inset_r = Some(x.w * 0.2);
|
||||
b.body.inset_t = Some(x.h * 0.08);
|
||||
}
|
||||
}
|
||||
paint(d, &mut sh, i, table);
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_accent_process(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Accent Process: a title box per step with its sub-points in a pale box below and to the
|
||||
// right, arrows between the titles.
|
||||
let gap = area.w * 0.05;
|
||||
let w = (area.w - gap * (nf - 1.0)) / nf;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let x = area.x + i as f64 * (w + gap);
|
||||
let th = area.h * 0.22;
|
||||
if g.len() > 1 {
|
||||
let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &l.nodes(&g[1..]), false);
|
||||
paint(d, &mut sh, i, true);
|
||||
if let Some(b) = &mut sh.text {
|
||||
b.body.inset_t = Some(th * 0.45);
|
||||
}
|
||||
out.push((sh, g[1..].to_vec()));
|
||||
}
|
||||
l.titled(out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]);
|
||||
if i + 1 < n {
|
||||
let a = gap * 0.8;
|
||||
l.arrow(out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_continuous_block_process(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Continuous Block Process: one big pale arrow behind a row of boxes.
|
||||
let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0);
|
||||
paint(d, &mut big, 0, true);
|
||||
out.push((big, vec![]));
|
||||
let w = area.w * 0.85 / nf;
|
||||
let h = area.h * 0.32;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let x = area.x + area.w * 0.02 + i as f64 * w;
|
||||
l.titled(out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_bending_process(l: &L, out: &mut Vec<Item>) {
|
||||
let L { area, gs, n, nf, .. } = *l;
|
||||
// Basic Bending Process: rows of boxes read left to right, wrapping, arrows between.
|
||||
let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n);
|
||||
let rows = n.div_ceil(cols);
|
||||
let (cw, rh) = (area.w / cols as f64, area.h / rows as f64);
|
||||
let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6));
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (c, r) = (i % cols, i / cols);
|
||||
let x = area.x + c as f64 * cw;
|
||||
let y = area.y + r as f64 * rh + (rh - bh) / 2.0;
|
||||
l.titled(out, "rect", Xfrm::new(x, y, bw, bh), i, g);
|
||||
if i + 1 < n {
|
||||
let a = (cw - bw) * 0.7;
|
||||
if c + 1 < cols {
|
||||
l.arrow(out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i);
|
||||
} else {
|
||||
// The bend: down from the row's last box towards the next row's first.
|
||||
let a = (rh - bh).min(a * 1.5).max(6.0);
|
||||
l.arrow(out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_linear_venn(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom,
|
||||
// the text near each one's top.
|
||||
let linear = d.kind == "linearVenn";
|
||||
if linear {
|
||||
let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h);
|
||||
let step = dc * 0.75;
|
||||
let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &l.nodes(g), true);
|
||||
paint(d, &mut sh, i, false);
|
||||
if let Some(Fill::Solid { color: c }) = &mut sh.fill {
|
||||
*c = c.clone().with(ColorTransform::Alpha(55_000));
|
||||
}
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
} else {
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0);
|
||||
for (i, _) in gs.iter().enumerate() {
|
||||
let dc = s * (1.0 - i as f64 / nf);
|
||||
let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0);
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
// Each circle's text in the band between its top and the next circle's.
|
||||
let band = s / nf;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let dc = s * (1.0 - i as f64 / nf);
|
||||
let top = bottom - dc;
|
||||
let h = if i + 1 < n { band } else { dc };
|
||||
let w = dc * if i + 1 < n { 0.6 } else { 0.7 };
|
||||
let mut t = text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &l.nodes(g), true);
|
||||
paint(d, &mut t, i, false);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
t.effects = None;
|
||||
out.push((t, g.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_nested_target(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Nested Target: rounded boxes nested inside each other, the text on each one's top band.
|
||||
let band_x = area.w / 2.0 / nf * 0.6;
|
||||
let band_y = area.h / nf * 0.9 / 2.0;
|
||||
for (i, _) in gs.iter().enumerate() {
|
||||
let (dx, dy) = (band_x * i as f64, band_y * i as f64);
|
||||
let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0);
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (dx, dy) = (band_x * i as f64, band_y * i as f64);
|
||||
let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy };
|
||||
let mut t =
|
||||
text_shape("rect", Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), &l.nodes(g), true);
|
||||
paint(d, &mut t, i, false);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
t.effects = None;
|
||||
out.push((t, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_grid_matrix(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, .. } = *l;
|
||||
// Grid Matrix: four quadrants on crossed axes.
|
||||
let s = area.h.min(area.w);
|
||||
let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0);
|
||||
let t = s * 0.06;
|
||||
for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] {
|
||||
let mut sh = deco(preset, x, 0.0);
|
||||
paint(d, &mut sh, 0, true);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
let q = s / 2.0 - t * 1.2;
|
||||
for (i, g) in gs.iter().take(4).enumerate() {
|
||||
let (c, r) = ((i % 2) as f64, (i / 2) as f64);
|
||||
let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q);
|
||||
l.titled(out, "rect", x, i, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_gear(l: &L, out: &mut Vec<Item>) {
|
||||
let L { area, gs, .. } = *l;
|
||||
// Gear: up to three interlocking gears, the first the biggest.
|
||||
let s = area.h.min(area.w * 0.62);
|
||||
let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0);
|
||||
let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")];
|
||||
for (i, g) in gs.iter().take(3).enumerate() {
|
||||
let (fx, fy, fs, preset) = spots[i];
|
||||
let z = s * fs;
|
||||
l.titled(out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_funnel(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, .. } = *l;
|
||||
// Funnel: the ideas poured in at the top (up to three), the last one coming out below.
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, y0) = (area.x + area.w / 2.0, area.y);
|
||||
let fw = s * 0.8;
|
||||
let fh = s * 0.62;
|
||||
let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0);
|
||||
paint(d, &mut f, 0, true);
|
||||
out.push((f, vec![]));
|
||||
let (inside, last) = match gs.split_last() {
|
||||
Some((l, r)) if !r.is_empty() => (r, Some(l)),
|
||||
_ => (gs, None),
|
||||
};
|
||||
let k = inside.len().min(3);
|
||||
let dc = fw * 0.27;
|
||||
let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)];
|
||||
for (i, g) in inside.iter().take(3).enumerate() {
|
||||
let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] };
|
||||
l.titled(out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g);
|
||||
}
|
||||
if let Some(g) = last {
|
||||
let a = s * 0.12;
|
||||
let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0);
|
||||
paint(d, &mut ar, k, true);
|
||||
out.push((ar, vec![]));
|
||||
l.titled(out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_picture_h_list(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Horizontal Picture List: a column per node, a big picture over its text.
|
||||
let w = area.w / nf;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let x = area.x + i as f64 * w + w * 0.04;
|
||||
let bw = w * 0.92;
|
||||
let ph = area.h * 0.55;
|
||||
out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![]));
|
||||
let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &l.nodes(g), true);
|
||||
if let Some(b) = &mut sh.text {
|
||||
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
}
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_timeline(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Basic Timeline: an arrow across the middle, a dot per node on it, the text above and
|
||||
// below in turn.
|
||||
let mid = area.y + area.h / 2.0;
|
||||
let bar = (area.h * 0.08).max(6.0);
|
||||
let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0);
|
||||
paint(d, &mut sh, 0, true);
|
||||
out.push((sh, vec![]));
|
||||
let w = area.w * 0.92 / nf;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let cx = area.x + w * (i as f64 + 0.5);
|
||||
let dot = bar * 1.4;
|
||||
let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0);
|
||||
paint(d, &mut dsh, i, false);
|
||||
out.push((dsh, vec![]));
|
||||
let th = area.h / 2.0 - bar * 1.6;
|
||||
let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 };
|
||||
let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &l.nodes(g), true);
|
||||
if let Some(b) = &mut t.text {
|
||||
b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top });
|
||||
}
|
||||
paint(d, &mut t, i, true);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
out.push((t, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_venn(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, n, nf } = *l;
|
||||
// Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle).
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dc = if n == 1 { s } else { s * 0.62 };
|
||||
let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 };
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians();
|
||||
let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &l.nodes(g), true);
|
||||
paint(d, &mut sh, i, false);
|
||||
if let Some(Fill::Solid { color: c }) = &mut sh.fill {
|
||||
*c = c.clone().with(ColorTransform::Alpha(50_000));
|
||||
}
|
||||
if let Some(b) = &mut sh.text {
|
||||
// Text towards the circle's outer side, clear of the overlap.
|
||||
let p = dc * 0.36;
|
||||
let (dx, dy) = (a.cos() * p, a.sin() * p);
|
||||
b.body.inset_l = Some((7.2 + dx).max(2.0));
|
||||
b.body.inset_r = Some((7.2 - dx).max(2.0));
|
||||
b.body.inset_t = Some((3.6 + dy).max(2.0));
|
||||
b.body.inset_b = Some((3.6 - dy).max(2.0));
|
||||
}
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn arm_target(l: &L, out: &mut Vec<Item>) {
|
||||
let L { d, area, gs, nf, .. } = *l;
|
||||
// Basic Target: nested rings, the outermost first, each ring's text in a box beside it
|
||||
// with a line from the ring.
|
||||
let s = area.h.min(area.w * 0.5);
|
||||
let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0);
|
||||
let band = s / 2.0 / nf;
|
||||
for (i, _) in gs.iter().enumerate() {
|
||||
let dc = s - 2.0 * band * i as f64;
|
||||
let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0);
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
let tx = area.x + s + area.w * 0.06;
|
||||
let rh = area.h / nf;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let ty = area.y + rh * (i as f64 + 0.5);
|
||||
// The point on ring i's band level with the text, else its top.
|
||||
let ry = cy - s / 2.0 + band * (i as f64 + 0.5);
|
||||
out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![]));
|
||||
let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &l.nodes(g), true);
|
||||
paint(d, &mut t, i, true);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
if let Some(b) = &mut t.text {
|
||||
for p in &mut b.paragraphs {
|
||||
p.props.align = Some(Align::Left);
|
||||
}
|
||||
}
|
||||
out.push((t, g.clone()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::paint::layout_items;
|
||||
use super::{Diagram, DiagramNode, MAX_LEVEL, MAX_SHAPES, Shape, Xfrm};
|
||||
|
||||
/// Nodes from text lines: each leading tab is one outline level.
|
||||
pub fn nodes_from_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<DiagramNode> {
|
||||
lines
|
||||
.into_iter()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let tabs = l.chars().take_while(|c| *c == '\t').count();
|
||||
DiagramNode {
|
||||
text: l.trim().to_string(),
|
||||
level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL),
|
||||
picture: None,
|
||||
assistant: false,
|
||||
title: String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Levels made consistent: the first node is top level and no node is more than one level
|
||||
/// deeper than the node before it.
|
||||
pub fn normalize(nodes: &mut [DiagramNode]) {
|
||||
let mut prev: Option<u8> = None;
|
||||
for n in nodes.iter_mut() {
|
||||
n.level = match prev {
|
||||
None => 0,
|
||||
Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL),
|
||||
};
|
||||
prev = Some(n.level);
|
||||
}
|
||||
}
|
||||
|
||||
/// Index one past the last descendant of node `i`.
|
||||
fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize {
|
||||
let lv = nodes.get(i).map(|n| n.level).unwrap_or(0);
|
||||
let mut j = i + 1;
|
||||
while nodes.get(j).is_some_and(|n| n.level > lv) {
|
||||
j += 1;
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the
|
||||
/// new node's index.
|
||||
pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, position: &str, text: &str) -> usize {
|
||||
let Some(i) = index.filter(|i| *i < nodes.len()) else {
|
||||
nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() });
|
||||
return nodes.len() - 1;
|
||||
};
|
||||
let lv = nodes[i].level;
|
||||
let (at, level) = match position {
|
||||
"before" => (i, lv),
|
||||
"below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)),
|
||||
"above" => {
|
||||
let end = subtree_end(nodes, i);
|
||||
for n in &mut nodes[i..end] {
|
||||
n.level = (n.level + 1).min(MAX_LEVEL);
|
||||
}
|
||||
(i, lv)
|
||||
}
|
||||
"assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)),
|
||||
_ => (subtree_end(nodes, i), lv),
|
||||
};
|
||||
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() });
|
||||
at
|
||||
}
|
||||
|
||||
/// Remove node `i`; its sub-points move up one level.
|
||||
pub fn remove(nodes: &mut Vec<DiagramNode>, i: usize) -> bool {
|
||||
if i >= nodes.len() {
|
||||
return false;
|
||||
}
|
||||
let end = subtree_end(nodes, i);
|
||||
for n in &mut nodes[i + 1..end] {
|
||||
n.level = n.level.saturating_sub(1);
|
||||
}
|
||||
nodes.remove(i);
|
||||
normalize(nodes);
|
||||
true
|
||||
}
|
||||
|
||||
/// Move node `i` and its sub-points one level up. False when it is already top level.
|
||||
pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool {
|
||||
if nodes.get(i).is_none_or(|n| n.level == 0) {
|
||||
return false;
|
||||
}
|
||||
let end = subtree_end(nodes, i);
|
||||
for n in &mut nodes[i..end] {
|
||||
n.level -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Move node `i` and its sub-points one level down, under the node before it. False when there is
|
||||
/// nothing to go under.
|
||||
pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool {
|
||||
let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false };
|
||||
if cur.level > prev.level || cur.level >= MAX_LEVEL {
|
||||
return false;
|
||||
}
|
||||
let end = subtree_end(nodes, i);
|
||||
if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) {
|
||||
return false;
|
||||
}
|
||||
for n in &mut nodes[i..end] {
|
||||
n.level += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A generated shape and the nodes whose text it shows (none for arrows and lines).
|
||||
pub(super) type Item = (Shape, Vec<usize>);
|
||||
|
||||
/// Top-level nodes, each with its sub-points.
|
||||
pub(super) fn groups(d: &Diagram) -> Vec<Vec<usize>> {
|
||||
let mut out: Vec<Vec<usize>> = vec![];
|
||||
for (i, n) in d.nodes.iter().enumerate() {
|
||||
match out.last_mut() {
|
||||
Some(g) if n.level > 0 => g.push(i),
|
||||
_ => out.push(vec![i]),
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_SHAPES);
|
||||
out
|
||||
}
|
||||
|
||||
/// Read text typed into the diagram's shapes back into its nodes.
|
||||
pub(super) fn sync_text(d: &mut Diagram, children: &[Shape]) {
|
||||
if d.imported {
|
||||
return;
|
||||
}
|
||||
let map: Vec<Vec<usize>> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect();
|
||||
for (shape, idx) in children.iter().zip(map) {
|
||||
let Some(body) = &shape.text else { continue };
|
||||
if idx.is_empty() || body.paragraphs.len() != idx.len() {
|
||||
continue;
|
||||
}
|
||||
for (p, i) in body.paragraphs.iter().zip(idx) {
|
||||
if let Some(n) = d.nodes.get_mut(i) {
|
||||
n.text = p.text();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use super::layout::layout_raw;
|
||||
use super::nodes::Item;
|
||||
use super::{
|
||||
Align, AutoFit, ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Gradient, GradientShape, GradientStop, Line, MAX_LEVEL,
|
||||
Paragraph, SchemeSlot, Shadow, Shape, ShapeStyle, TextBody, Xfrm,
|
||||
};
|
||||
|
||||
fn slot(i: usize) -> SchemeSlot {
|
||||
[SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6]
|
||||
}
|
||||
|
||||
/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN`
|
||||
/// keeps one.
|
||||
pub(super) fn accent(d: &Diagram, i: usize) -> SchemeSlot {
|
||||
let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize);
|
||||
if let Some(r) = d.colors.strip_prefix("range")
|
||||
&& let Some(a @ 1..=6) = digit(r, 0)
|
||||
{
|
||||
// Colorful Range: alternate between two neighbouring accents.
|
||||
return slot(a - 1 + i % 2);
|
||||
}
|
||||
match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) {
|
||||
Some(n @ 1..=6) => slot(n - 1),
|
||||
_ => slot(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient Range: each shape a step darker than the one before (the steps cycle).
|
||||
pub(super) fn shade(d: &Diagram, i: usize) -> Option<ColorTransform> {
|
||||
d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000))
|
||||
}
|
||||
|
||||
/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points)
|
||||
/// lit by the three-point rig.
|
||||
fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String {
|
||||
format!(
|
||||
"<a:scene3d><a:camera prst=\"{camera}\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d prstMaterial=\"matte\"><a:bevelT w=\"{}\" h=\"{}\" prst=\"{bevel}\"/></a:sp3d>",
|
||||
(w * 12700.0).round() as i64,
|
||||
(h * 12700.0).round() as i64
|
||||
)
|
||||
}
|
||||
|
||||
/// Brick Scene's camera: the diagram's shapes seen from above and to the side.
|
||||
pub const BRICK_CAMERA: &str = "isometricOffAxis2Top";
|
||||
|
||||
/// Whether `style` is one of the 3-D SmartArt Styles.
|
||||
pub fn is_3d(style: &str) -> bool {
|
||||
matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick")
|
||||
}
|
||||
|
||||
/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet
|
||||
/// boxes of the bullet lists) are a pale tint of the accent with dark text.
|
||||
pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
|
||||
let a = accent(d, i);
|
||||
let base = match shade(d, i) {
|
||||
Some(t) => ColorRef::scheme(a).with(t),
|
||||
None => ColorRef::scheme(a),
|
||||
};
|
||||
let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000));
|
||||
let dark_text = light || matches!(d.style.as_str(), "outline" | "powder");
|
||||
sh.style = Some(ShapeStyle::accent(a));
|
||||
match d.style.as_str() {
|
||||
_ if light => {
|
||||
sh.fill = Some(Fill::solid(tint(80)));
|
||||
sh.line = Some(Line::solid(tint(60), 1.0));
|
||||
}
|
||||
"outline" => {
|
||||
sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1)));
|
||||
sh.line = Some(Line::solid(base.clone(), 2.25));
|
||||
}
|
||||
"subtle" => {
|
||||
sh.fill = Some(Fill::solid(base.clone()));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5));
|
||||
sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() });
|
||||
}
|
||||
"polished" | "inset" | "cartoon" | "powder" | "brick" => {
|
||||
let (bevel, bw, line, fill) = match d.style.as_str() {
|
||||
"polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()),
|
||||
"inset" => ("relaxedInset", 10.0, None, base.clone()),
|
||||
"cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()),
|
||||
"powder" => ("convex", 14.0, None, tint(40)),
|
||||
_ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()),
|
||||
};
|
||||
sh.fill = Some(Fill::solid(fill));
|
||||
sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() }));
|
||||
sh.effects = Some(Effects {
|
||||
outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)),
|
||||
bevel: Some(bevel.into()),
|
||||
raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
"intense" => {
|
||||
sh.fill = Some(Fill::Gradient(Gradient {
|
||||
stops: vec![
|
||||
GradientStop { pos: 0.0, color: tint(30) },
|
||||
GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) },
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
}));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
|
||||
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
|
||||
}
|
||||
_ => {
|
||||
sh.fill = Some(Fill::solid(base.clone()));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5));
|
||||
}
|
||||
}
|
||||
if let Some(body) = &mut sh.text {
|
||||
let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) };
|
||||
for p in &mut body.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
r.props.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow(blur: f64, dist: f64) -> Shadow {
|
||||
Shadow {
|
||||
color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)),
|
||||
blur,
|
||||
dist,
|
||||
dir: 90.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With
|
||||
/// `title` false every line is a bullet (the bullet boxes of the bullet lists).
|
||||
pub(super) fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape {
|
||||
let mut body = TextBody::default();
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0);
|
||||
for (k, n) in lines.iter().enumerate() {
|
||||
let mut para = Paragraph::new(&n.text);
|
||||
let head = title && k == 0;
|
||||
if head {
|
||||
para.props.align = Some(Align::Center);
|
||||
} else {
|
||||
para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL);
|
||||
para.props.align = Some(Align::Left);
|
||||
para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() });
|
||||
para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0));
|
||||
para.props.indent = Some(-10.0);
|
||||
}
|
||||
let size = if head { 20.0 } else { 14.0 };
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(size);
|
||||
}
|
||||
para.end_props.size = Some(size);
|
||||
body.paragraphs.push(para);
|
||||
}
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Paragraph::new(""));
|
||||
}
|
||||
if !title {
|
||||
body.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
}
|
||||
Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A plain shape with no text (arrows between steps).
|
||||
pub(super) fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape {
|
||||
let mut x = x;
|
||||
x.rot = rot;
|
||||
Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }
|
||||
}
|
||||
|
||||
pub(super) fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape {
|
||||
let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs());
|
||||
x.flip_h = (b.0 < a.0) != (b.1 < a.1);
|
||||
Shape {
|
||||
name: "SmartArt connector".into(),
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset("line"),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagram's shapes laid out in `area` (ids not yet assigned).
|
||||
pub fn layout(d: &Diagram, area: Xfrm) -> Vec<Shape> {
|
||||
layout_items(d, area).into_iter().map(|(s, _)| s).collect()
|
||||
}
|
||||
|
||||
/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is
|
||||
/// shrunk per shape so its longest word fits on a line.
|
||||
pub(super) fn layout_items(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
let mut v = layout_raw(d, area);
|
||||
for (sh, _) in &mut v {
|
||||
fit_words(sh);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Rough advance of a character as a fraction of the font size (a sans text face).
|
||||
const CHAR_W: f64 = 0.6;
|
||||
|
||||
/// Smallest font scale shrinking to fit goes to.
|
||||
pub(super) const MIN_SCALE: f64 = 0.25;
|
||||
|
||||
/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so
|
||||
/// no word is wider than its line and would break mid-word. Overflow in height is left to the
|
||||
/// body's shrink-on-overflow autofit.
|
||||
fn fit_words(sh: &mut Shape) {
|
||||
let Some(w) = sh.xfrm.map(|x| x.w) else { return };
|
||||
// Round shapes keep their text in the inscribed rectangle (about 71% of the width).
|
||||
let text_w = match sh.geom.preset_name() {
|
||||
Some("ellipse" | "gear6" | "gear9") => w * 0.71,
|
||||
_ => w,
|
||||
};
|
||||
let Some(body) = &mut sh.text else { return };
|
||||
let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2);
|
||||
let mut scale: f64 = 1.0;
|
||||
for p in &body.paragraphs {
|
||||
let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0);
|
||||
for r in &p.runs {
|
||||
let size = r.props.size.unwrap_or(18.0);
|
||||
let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64;
|
||||
let need = word * size * CHAR_W;
|
||||
if need > 0.0 {
|
||||
scale = scale.min(line.max(0.0) / need);
|
||||
}
|
||||
}
|
||||
}
|
||||
let scale = scale.max(MIN_SCALE);
|
||||
if scale >= 0.999 {
|
||||
return;
|
||||
}
|
||||
let shrink = |v: &mut Option<f64>| {
|
||||
if let Some(x) = v {
|
||||
*x = (*x * scale * 2.0).floor() / 2.0;
|
||||
}
|
||||
};
|
||||
for p in &mut body.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
shrink(&mut r.props.size);
|
||||
}
|
||||
shrink(&mut p.end_props.size);
|
||||
shrink(&mut p.props.margin_left);
|
||||
if let Some(i) = &mut p.props.indent {
|
||||
*i *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture placeholder for node `node`: its picture when it has one, else a pale box.
|
||||
pub(super) fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape {
|
||||
let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() };
|
||||
paint(d, &mut sh, i, true);
|
||||
if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) {
|
||||
sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() }));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5));
|
||||
}
|
||||
sh
|
||||
}
|
||||
@@ -4,28 +4,49 @@ use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::{Shape, ShapeKind, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, bad, bool_or, bool_param, cmd, color_param, edit_shapes, f64_param, has_selection, has_slide, id_param, ok, opt_usize_param,
|
||||
str_param, table_drawn_heights, xfrm_of,
|
||||
};
|
||||
use crate::{DocState, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("table.insertRowAbove", "Insert Above", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "rowAbove"
|
||||
s,
|
||||
p,
|
||||
"rowAbove",
|
||||
"table.insertRowAbove"
|
||||
)),
|
||||
cmd!("table.insertRowBelow", "Insert Below", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "rowBelow"
|
||||
s,
|
||||
p,
|
||||
"rowBelow",
|
||||
"table.insertRowBelow"
|
||||
)),
|
||||
cmd!("table.insertColumnLeft", "Insert Left", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "colLeft"
|
||||
s,
|
||||
p,
|
||||
"colLeft",
|
||||
"table.insertColumnLeft"
|
||||
)),
|
||||
cmd!("table.insertColumnRight", "Insert Right", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "colRight"
|
||||
s,
|
||||
p,
|
||||
"colRight",
|
||||
"table.insertColumnRight"
|
||||
)),
|
||||
cmd!("table.deleteRow", "Delete Rows", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "delRow"
|
||||
s,
|
||||
p,
|
||||
"delRow",
|
||||
"table.deleteRow"
|
||||
)),
|
||||
cmd!("table.deleteColumn", "Delete Columns", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "delCol"
|
||||
s,
|
||||
p,
|
||||
"delCol",
|
||||
"table.deleteColumn"
|
||||
)),
|
||||
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
|
||||
cmd!(
|
||||
@@ -87,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align Cell Text",
|
||||
["Table Layout", "Alignment"],
|
||||
None,
|
||||
"{anchor: top|middle|bottom, cells?, id?}",
|
||||
"{anchor?: top|middle|bottom (vertical; top when neither anchor nor align is given), align?: left|center|right|justify (horizontal, applied to every paragraph of the cells), cells?, id?}",
|
||||
has_selection,
|
||||
cell_align
|
||||
),
|
||||
@@ -225,16 +246,26 @@ impl TableFrame {
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
/// A `[r, c]` cell param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid cell, so a bad cell never acts on the selection.
|
||||
fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>> {
|
||||
let v = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
match (idx(0), idx(1)) {
|
||||
(Some(r), Some(c)) => Ok(Some((r, c))),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The table's row/col to act on: params, the edited cell, or the selected cells.
|
||||
fn at(s: &Session, p: &Value) -> (usize, usize) {
|
||||
/// A present but invalid `row`/`col` is an error; only an absent one falls back.
|
||||
fn at(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let st = s.active();
|
||||
let cell = st.and_then(|d| d.selection.text.as_ref().and_then(|t| t.cell)).or_else(|| st.and_then(|d| d.selection.cells.map(|c| c.1)));
|
||||
(usize_param(p, "row").or(cell.map(|c| c.0)).unwrap_or(0), usize_param(p, "col").or(cell.map(|c| c.1)).unwrap_or(0))
|
||||
Ok((opt_usize_param(p, "row", cmd)?.or(cell.map(|c| c.0)).unwrap_or(0), opt_usize_param(p, "col", cmd)?.or(cell.map(|c| c.1)).unwrap_or(0)))
|
||||
}
|
||||
|
||||
fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result<Value> {
|
||||
@@ -282,10 +313,10 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let (r, c) = at(s, p);
|
||||
fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result<Value> {
|
||||
let (r, c) = at(s, p, name)?;
|
||||
let op = op.to_string();
|
||||
with_table(s, p, "table", move |t| {
|
||||
with_table(s, p, name, move |t| {
|
||||
match op.as_str() {
|
||||
"rowAbove" => t.insert_row(r),
|
||||
"rowBelow" => t.insert_row(r.saturating_add(1)),
|
||||
@@ -293,12 +324,12 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
"colRight" => t.insert_col(c.saturating_add(1)),
|
||||
"delRow" => {
|
||||
if !t.delete_row(r) {
|
||||
return Err(bad("table.deleteRow", "can't delete the last row"));
|
||||
return Err(bad(name, "can't delete the last row"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !t.delete_col(c) {
|
||||
return Err(bad("table.deleteColumn", "can't delete the last column"));
|
||||
return Err(bad(name, "can't delete the last column"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,8 +339,8 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel = s.doc()?.selection.cells;
|
||||
let from = cell_param(p, "from").or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
|
||||
let to = cell_param(p, "to").or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
|
||||
let from = cell_param(p, "from", "table.merge")?.or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
|
||||
let to = cell_param(p, "to", "table.merge")?.or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
|
||||
with_table(
|
||||
s,
|
||||
p,
|
||||
@@ -319,7 +350,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cell = cell_param(p, "cell").unwrap_or_else(|| at(s, p));
|
||||
let cell = match cell_param(p, "cell", "table.split")? {
|
||||
Some(c) => c,
|
||||
None => at(s, p, "table.split")?,
|
||||
};
|
||||
with_table(s, p, "table.split", move |t| if t.split(cell.0, cell.1) { Ok(()) } else { Err(bad("table.split", "that cell isn't merged")) })
|
||||
}
|
||||
|
||||
@@ -501,18 +535,43 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::Anchor;
|
||||
let a = match str_param(p, "anchor").unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
// A value that is not a string is refused, not taken as absent.
|
||||
for k in ["align", "anchor"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_null() && !v.is_string()) {
|
||||
return Err(bad("table.cellAlign", format!("`{k}` is a name such as {}", if k == "align" { "left or center" } else { "top or middle" })));
|
||||
}
|
||||
}
|
||||
// Without `align`, the vertical anchor is the whole command (top when not given).
|
||||
let align = match str_param(p, "align") {
|
||||
None => None,
|
||||
Some("left" | "l") => Some(Align::Left),
|
||||
Some("center" | "centre" | "ctr") => Some(Align::Center),
|
||||
Some("right" | "r") => Some(Align::Right),
|
||||
Some("justify" | "just") => Some(Align::Justify),
|
||||
Some(o) => return Err(bad("table.cellAlign", format!("unknown align {o:?}; one of left, center, right, justify"))),
|
||||
};
|
||||
let anchor = match (str_param(p, "anchor"), align) {
|
||||
(None, Some(_)) => None,
|
||||
(a, _) => Some(match a.unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.anchor = Some(a);
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
}
|
||||
if let Some(a) = align {
|
||||
for para in &mut cell.text.paragraphs {
|
||||
para.props.align = Some(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -533,7 +592,7 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (_, c) = at(s, p);
|
||||
let (_, c) = at(s, p, "table.columnWidth")?;
|
||||
let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0);
|
||||
let keep_total = bool_or(p, "keepTotal", false);
|
||||
with_table(s, p, "table.columnWidth", move |t| {
|
||||
@@ -562,7 +621,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn row_height(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (r, _) = at(s, p);
|
||||
let (r, _) = at(s, p, "table.rowHeight")?;
|
||||
let h = f64_param(p, "height").ok_or_else(|| bad("table.rowHeight", "missing `height`"))?.clamp(4.0, 4000.0);
|
||||
with_table(s, p, "table.rowHeight", move |t| {
|
||||
if let Some(x) = t.rows.get_mut(r) {
|
||||
@@ -577,8 +636,8 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(i) => i,
|
||||
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("table.selectCells", "select a table"))?,
|
||||
};
|
||||
let from = cell_param(p, "from").unwrap_or((0, 0));
|
||||
let to = cell_param(p, "to").unwrap_or(from);
|
||||
let from = cell_param(p, "from", "table.selectCells")?.unwrap_or((0, 0));
|
||||
let to = cell_param(p, "to", "table.selectCells")?.unwrap_or(from);
|
||||
s.select(|_, sel| {
|
||||
sel.cells = Some((id, from, to));
|
||||
sel.shapes = vec![id];
|
||||
|
||||
+25
-2039
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
use super::{Arc, Result, Session, TextSel, Value, bad, body_mut, body_of, ed, edit_text, json, str_param};
|
||||
|
||||
/// Built-in AutoCorrect replacements (common slips and symbols).
|
||||
pub const AUTOCORRECT: &[(&str, &str)] = &[
|
||||
("teh", "the"),
|
||||
("adn", "and"),
|
||||
("recieve", "receive"),
|
||||
("recieved", "received"),
|
||||
("seperate", "separate"),
|
||||
("occured", "occurred"),
|
||||
("occurence", "occurrence"),
|
||||
("thier", "their"),
|
||||
("wich", "which"),
|
||||
("acheive", "achieve"),
|
||||
("accross", "across"),
|
||||
("adress", "address"),
|
||||
("alot", "a lot"),
|
||||
("appearence", "appearance"),
|
||||
("arguement", "argument"),
|
||||
("basicly", "basically"),
|
||||
("beggining", "beginning"),
|
||||
("beleive", "believe"),
|
||||
("calender", "calendar"),
|
||||
("commitee", "committee"),
|
||||
("definately", "definitely"),
|
||||
("dont", "don't"),
|
||||
("doesnt", "doesn't"),
|
||||
("didnt", "didn't"),
|
||||
("isnt", "isn't"),
|
||||
("embarass", "embarrass"),
|
||||
("enviroment", "environment"),
|
||||
("existance", "existence"),
|
||||
("foward", "forward"),
|
||||
("freind", "friend"),
|
||||
("goverment", "government"),
|
||||
("happend", "happened"),
|
||||
("i", "I"),
|
||||
("ive", "I've"),
|
||||
("immediatly", "immediately"),
|
||||
("independant", "independent"),
|
||||
("knowlege", "knowledge"),
|
||||
("libary", "library"),
|
||||
("neccessary", "necessary"),
|
||||
("necesary", "necessary"),
|
||||
("noticable", "noticeable"),
|
||||
("occassion", "occasion"),
|
||||
("posession", "possession"),
|
||||
("prefered", "preferred"),
|
||||
("publically", "publicly"),
|
||||
("realy", "really"),
|
||||
("recomend", "recommend"),
|
||||
("refered", "referred"),
|
||||
("relevent", "relevant"),
|
||||
("succesful", "successful"),
|
||||
("tommorow", "tomorrow"),
|
||||
("tomorow", "tomorrow"),
|
||||
("truely", "truly"),
|
||||
("untill", "until"),
|
||||
("wierd", "weird"),
|
||||
("youre", "you're"),
|
||||
("(c)", "©"),
|
||||
("(r)", "®"),
|
||||
("(tm)", "™"),
|
||||
("(e)", "€"),
|
||||
("...", "…"),
|
||||
("->", "→"),
|
||||
("<-", "←"),
|
||||
("=>", "⇒"),
|
||||
("--", "–"),
|
||||
];
|
||||
|
||||
const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
||||
|
||||
/// The replacement for `word` from your entries, then the built-in list.
|
||||
fn replacement(prefs: &crate::Prefs, word: &str) -> Option<String> {
|
||||
if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) {
|
||||
return Some(to.clone());
|
||||
}
|
||||
if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) {
|
||||
return Some(to.clone());
|
||||
}
|
||||
let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?;
|
||||
if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) {
|
||||
return None;
|
||||
}
|
||||
// "i" only on its own, lower case.
|
||||
if *k == "i" && word != "i" {
|
||||
return None;
|
||||
}
|
||||
Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() })
|
||||
}
|
||||
|
||||
/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces
|
||||
/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was
|
||||
/// typed (the case rules apply only then).
|
||||
pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option<String> {
|
||||
let mut out = replacement(prefs, word);
|
||||
if typing && out.is_none() {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
let letters = chars.iter().all(|c| c.is_alphabetic());
|
||||
if prefs.ac_fractions {
|
||||
out = match word {
|
||||
"1/2" => Some("½".into()),
|
||||
"1/4" => Some("¼".into()),
|
||||
"3/4" => Some("¾".into()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if out.is_none() && letters && chars.len() >= 3 {
|
||||
let upper = |c: &char| c.is_uppercase();
|
||||
let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase()));
|
||||
let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper));
|
||||
if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower {
|
||||
out =
|
||||
Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::<String>() } else { c.to_string() }).collect());
|
||||
} else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper {
|
||||
out = Some(
|
||||
chars
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect() })
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if out.is_none() && prefs.ac_days && DAYS.contains(&word) {
|
||||
out = Some(ed::change_case(word, "sentence"));
|
||||
}
|
||||
}
|
||||
if typing && prefs.ac_sentence_caps && sentence_start {
|
||||
let w = out.clone().unwrap_or_else(|| word.to_string());
|
||||
let mut cs = w.chars();
|
||||
if let Some(f) = cs.next()
|
||||
&& f.is_lowercase()
|
||||
&& w.chars().all(char::is_alphabetic)
|
||||
{
|
||||
out = Some(f.to_uppercase().chain(cs).collect());
|
||||
}
|
||||
}
|
||||
out.filter(|o| o != word)
|
||||
}
|
||||
|
||||
/// After a space or punctuation, correct the word just typed.
|
||||
pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
if !s.prefs.autocorrect && !(typing && s.prefs.ac_lists) {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let Some(t) = st.selection.text.clone() else { return Ok(()) };
|
||||
let Some(body) = body_of(st, &t) else { return Ok(()) };
|
||||
let line: Vec<char> = body.paragraphs.get(t.caret.0).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
let end = t.caret.1;
|
||||
if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) {
|
||||
return Ok(());
|
||||
}
|
||||
if typing && s.prefs.ac_lists {
|
||||
// A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list.
|
||||
let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if !s.prefs.autocorrect {
|
||||
return Ok(());
|
||||
}
|
||||
let mut word_end = end - 1;
|
||||
let mut start = word_end;
|
||||
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
|
||||
start -= 1;
|
||||
}
|
||||
// "word," and "word." also count: the punctuation was typed before the space.
|
||||
let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default();
|
||||
if replacement(&s.prefs, &word).is_none() {
|
||||
let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c));
|
||||
if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") {
|
||||
word_end -= word.chars().count() - trimmed.chars().count();
|
||||
word = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None);
|
||||
let sentence_start = before.is_none_or(|c| ".!?".contains(c));
|
||||
// "word..." becomes "word…", the word corrected too.
|
||||
let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some();
|
||||
let rep = if ellipsis {
|
||||
let stem = word.trim_end_matches('.');
|
||||
Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string())))
|
||||
} else {
|
||||
correct_word(&s.prefs, &word, sentence_start, typing)
|
||||
};
|
||||
let Some(rep) = rep else {
|
||||
if typing && s.prefs.ac_ordinals && is_ordinal(&word) {
|
||||
return superscript_suffix(s, &t, word_end);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let delta = rep.chars().count() as isize - word.chars().count() as isize;
|
||||
let st = s.doc_mut()?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let sel = st.selection.clone();
|
||||
if let Some(b) = body_mut(&mut d, &sel, &t) {
|
||||
ed::delete(b, (t.caret.0, start), (t.caret.0, word_end));
|
||||
ed::insert(b, (t.caret.0, start), &rep, None);
|
||||
}
|
||||
st.doc = Arc::new(d);
|
||||
let c = (t.caret.0, (end as isize + delta).max(0) as usize);
|
||||
if let Some(tt) = st.selection.text.as_mut() {
|
||||
tt.caret = c;
|
||||
tt.anchor = c;
|
||||
}
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A list AutoFormat started: the text being edited right after it, the typed lead with its
|
||||
/// space, the paragraph's own formatting before, and the revision the change left.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutoList {
|
||||
sel: TextSel,
|
||||
typed: String,
|
||||
props: deckcraft_model::text::ParaProps,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and
|
||||
/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering
|
||||
/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`.
|
||||
fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> {
|
||||
let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c})));
|
||||
let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme})));
|
||||
match lead {
|
||||
"*" => bullet("•"),
|
||||
"-" | "–" => bullet("–"),
|
||||
"--" | "—" => bullet("■"),
|
||||
">" => bullet("➢"),
|
||||
"<>" => bullet("◆"),
|
||||
"1." => number("arabicPeriod"),
|
||||
"1)" => number("arabicParenR"),
|
||||
"I." => number("romanUcPeriod"),
|
||||
"i." => number("romanLcPeriod"),
|
||||
"A." => number("alphaUcPeriod"),
|
||||
"a." => number("alphaLcPeriod"),
|
||||
"a)" => number("alphaLcParenR"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a
|
||||
/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed
|
||||
/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed.
|
||||
fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result<bool> {
|
||||
if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') {
|
||||
return Ok(false);
|
||||
}
|
||||
let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) };
|
||||
let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
let para = t.caret.0;
|
||||
let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default();
|
||||
edit_text(s, |body, tt| {
|
||||
ed::delete(body, (para, 0), (para, end));
|
||||
tt.anchor = (para, 0);
|
||||
tt.caret = (para, 0);
|
||||
})?;
|
||||
s.execute(id, ¶ms)?;
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(sel) = st.selection.text.clone() {
|
||||
st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision });
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and
|
||||
/// space and the formatting it had. `false` when the last change was anything else.
|
||||
pub(super) fn undo_auto_list(s: &mut Session) -> Result<bool> {
|
||||
let st = s.doc_mut()?;
|
||||
let Some(a) = st.auto_list.take() else { return Ok(false) };
|
||||
if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_text(s, |body, t| {
|
||||
if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) {
|
||||
p.props = a.props.clone();
|
||||
}
|
||||
let c = ed::insert(body, a.sel.caret, &a.typed, None);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
|
||||
/// superscripts it.
|
||||
fn is_ordinal(word: &str) -> bool {
|
||||
let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase());
|
||||
let suffix = word.get(digits.len()..).unwrap_or_default();
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let d = digits.as_bytes();
|
||||
let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1');
|
||||
let want = match d.last() {
|
||||
_ if teen => "th",
|
||||
Some(b'1') => "st",
|
||||
Some(b'2') => "nd",
|
||||
Some(b'3') => "rd",
|
||||
_ => "th",
|
||||
};
|
||||
suffix == want
|
||||
}
|
||||
|
||||
/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix).
|
||||
fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> {
|
||||
let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end));
|
||||
let st = s.doc_mut()?;
|
||||
if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
let sel = st.selection.clone();
|
||||
if let Some(body) = body_mut(&mut d, &sel, t) {
|
||||
ed::format(body, a, b, &|r| r.baseline = Some(0.3));
|
||||
}
|
||||
st.doc = Arc::new(d);
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pr = &mut s.prefs;
|
||||
for (key, slot) in [
|
||||
("on", &mut pr.autocorrect),
|
||||
("twoInitials", &mut pr.ac_two_initials),
|
||||
("sentenceCaps", &mut pr.ac_sentence_caps),
|
||||
("days", &mut pr.ac_days),
|
||||
("capsLock", &mut pr.ac_caps_lock),
|
||||
("fractions", &mut pr.ac_fractions),
|
||||
("ordinals", &mut pr.ac_ordinals),
|
||||
("lists", &mut pr.ac_lists),
|
||||
] {
|
||||
if let Some(v) = p.get(key).and_then(Value::as_bool) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(a)) = p.get("add") {
|
||||
let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else {
|
||||
return Err(bad("text.autocorrect", "`add` is [from, to]"));
|
||||
};
|
||||
let from = from.trim();
|
||||
if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 {
|
||||
return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255"));
|
||||
}
|
||||
pr.ac_entries.retain(|(k, _)| k != from);
|
||||
pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from));
|
||||
if pr.ac_entries.len() >= 2000 {
|
||||
return Err(bad("text.autocorrect", "the list is full"));
|
||||
}
|
||||
pr.ac_entries.push((from.to_string(), to.to_string()));
|
||||
}
|
||||
if let Some(from) = str_param(p, "remove") {
|
||||
let had = pr.ac_entries.len();
|
||||
pr.ac_entries.retain(|(k, _)| k != from);
|
||||
if had == pr.ac_entries.len()
|
||||
&& AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from))
|
||||
&& !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from))
|
||||
{
|
||||
pr.ac_removed.push(from.to_string());
|
||||
}
|
||||
}
|
||||
let list: Vec<(String, String)> = pr
|
||||
.ac_entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
AUTOCORRECT
|
||||
.iter()
|
||||
.filter(|(k, _)| {
|
||||
!pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k))
|
||||
})
|
||||
.map(|(k, v)| (k.to_string(), v.to_string())),
|
||||
)
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
|
||||
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
|
||||
"ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
use super::autocorrect::undo_auto_list;
|
||||
use super::editing::{inherited_aligns, insert_text, promote_rtl_if_needed};
|
||||
use super::ime::{composition_range, model_pos, walk_backward, walk_forward};
|
||||
use super::layout::{ctx_for, fit_text_box, layout_for, pos_param};
|
||||
use super::{
|
||||
DocState, HistoryEntry, Pos, Result, Session, ShapeId, ShapeKind, TextBody, Value, bad, body_of, bool_or, ed, edit_shapes, edit_text, id_param,
|
||||
json, ok, push_undo, str_param, xfrm_of,
|
||||
};
|
||||
|
||||
/// A table cell's text laid out the way it is drawn, mapped into slide coordinates.
|
||||
pub(super) fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<CellText> {
|
||||
let sh = st.shape(id)?;
|
||||
let ShapeKind::Table(tb) = &sh.kind else { return None };
|
||||
let cell = tb.cell(row, col)?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb);
|
||||
let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2;
|
||||
let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell);
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let tmp = deckcraft_model::Shape::table_cell(body.clone());
|
||||
let layout = deckcraft_text::layout(
|
||||
&ctx,
|
||||
&tmp,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation);
|
||||
Some(CellText { layout, to_slide })
|
||||
}
|
||||
|
||||
pub(super) struct CellText {
|
||||
pub(super) layout: deckcraft_text::TextLayout,
|
||||
pub(super) to_slide: deckcraft_geom::Affine,
|
||||
}
|
||||
|
||||
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||
insert_text(s, &text, None)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
if dir == "backward" && undo_auto_list(s)? {
|
||||
return ok();
|
||||
}
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = if dir == "wordBackward" || dir == "wordForward" {
|
||||
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
||||
// (PowerPoint for Windows).
|
||||
ed::delete_word(body, a, dir == "wordForward")
|
||||
} else if a != b {
|
||||
ed::delete(body, a, b)
|
||||
} else {
|
||||
let other = match dir.as_str() {
|
||||
"forward" => ed::char_move(body, a, true),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
&& let Some(para) = body.paragraphs.get_mut(a.0)
|
||||
&& para.level > 0
|
||||
{
|
||||
para.level -= 1;
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
return;
|
||||
}
|
||||
ed::char_move(body, a, false)
|
||||
}
|
||||
};
|
||||
ed::delete(body, a, other)
|
||||
};
|
||||
t.anchor = at;
|
||||
t.caret = at;
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A `before`/`after` count from a control or input-method request.
|
||||
///
|
||||
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
|
||||
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
|
||||
/// would delete different text than asked.
|
||||
pub(super) fn count_param(p: &Value, name: &str) -> usize {
|
||||
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
}
|
||||
|
||||
/// Delete at most the requested character or byte counts around the selection or pre-edit,
|
||||
/// preserving selection direction and composition boundaries and recording one undo step
|
||||
/// outside composition.
|
||||
pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = count_param(p, "before");
|
||||
let after = count_param(p, "after");
|
||||
let bytes = bool_or(p, "bytes", false);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
|
||||
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
|
||||
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
|
||||
let ordered = {
|
||||
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
if a <= b { (a, b) } else { (b, a) }
|
||||
};
|
||||
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
|
||||
// scalars fully inside the requested before/after ranges around it are removed.
|
||||
let (start, end) = match comp {
|
||||
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
|
||||
None => ordered,
|
||||
};
|
||||
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
|
||||
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
|
||||
let deleted = before_chars + after_chars;
|
||||
if deleted == 0 {
|
||||
return Ok(json!({"deleted": 0}));
|
||||
}
|
||||
let before_doc = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let composing = st.composition.is_some();
|
||||
let vertex_kind = s.tool.vertex_kind.clone();
|
||||
edit_text(s, |body, t| {
|
||||
// Delete the after-range first, so the positions of the earlier range stay valid; then
|
||||
// the text before the selection.
|
||||
if after_chars > 0 {
|
||||
ed::delete(body, end, to);
|
||||
}
|
||||
if before_chars > 0 {
|
||||
ed::delete(body, from, start);
|
||||
}
|
||||
t.anchor = remap_after_delete(t.anchor, start, from, before_chars);
|
||||
t.caret = remap_after_delete(t.caret, start, from, before_chars);
|
||||
t.line_end = None;
|
||||
})?;
|
||||
if composing {
|
||||
if let Some(c) = s.doc_mut()?.composition.as_mut() {
|
||||
c.start = if before_chars == 0 {
|
||||
start
|
||||
} else if from.0 == start.0 {
|
||||
(start.0, start.1 - before_chars)
|
||||
} else {
|
||||
from
|
||||
};
|
||||
}
|
||||
} else {
|
||||
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind });
|
||||
}
|
||||
Ok(json!({"deleted": deleted}))
|
||||
}
|
||||
|
||||
/// Map a text position across the deletion of `before_chars` characters that begins at `start`
|
||||
/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier
|
||||
/// paragraph those paragraphs merge, so later paragraph numbers shift too.
|
||||
fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) {
|
||||
if before_chars == 0 || pos < start {
|
||||
return pos;
|
||||
}
|
||||
if from.0 == start.0 {
|
||||
// Only endpoints in the start paragraph move; later paragraphs keep their text.
|
||||
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
|
||||
} else {
|
||||
(from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
|
||||
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
||||
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
||||
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
||||
Some(line) => line.rtl,
|
||||
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_text::Affinity;
|
||||
let to = str_param(p, "to").unwrap_or("right").to_string();
|
||||
let extend = bool_or(p, "extend", false);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.move", "not editing text"))?;
|
||||
let body = body_of(st, &t).cloned().unwrap_or_default();
|
||||
let layout = layout_for(st, &t);
|
||||
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
||||
let caret = t.caret;
|
||||
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
||||
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
||||
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
||||
let to = match (to.as_str(), rtl) {
|
||||
("left", true) => "right",
|
||||
("right", true) => "left",
|
||||
("wordLeft", true) => "wordRight",
|
||||
("wordRight", true) => "wordLeft",
|
||||
(x, _) => x,
|
||||
}
|
||||
.to_string();
|
||||
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
||||
let mut upstream = false;
|
||||
let new = match to.as_str() {
|
||||
"left" if t.is_range() && !extend => collapse_to(true),
|
||||
"right" if t.is_range() && !extend => collapse_to(false),
|
||||
"left" => ed::char_move(&body, caret, false),
|
||||
"right" => ed::char_move(&body, caret, true),
|
||||
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
||||
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
||||
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
||||
"paraStart" => (caret.0, 0),
|
||||
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
"paraUp" => ed::para_up(&body, caret),
|
||||
"paraDown" => ed::para_down(&body, caret),
|
||||
"start" => (0, 0),
|
||||
"end" => ed::end(&body),
|
||||
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
||||
Some(l) => {
|
||||
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
||||
let (r, a) = match to.as_str() {
|
||||
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
||||
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
||||
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
||||
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
||||
_ => l.line_end_caret(lp(caret), aff),
|
||||
};
|
||||
upstream = a == Affinity::Upstream;
|
||||
(r.para, r.ch)
|
||||
}
|
||||
None => match to.as_str() {
|
||||
"up" => (caret.0.saturating_sub(1), caret.1),
|
||||
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"lineStart" => (caret.0, 0),
|
||||
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
},
|
||||
},
|
||||
other => return Err(bad("text.move", format!("unknown direction `{other}`"))),
|
||||
};
|
||||
let new = (new.0.min(body.paragraphs.len().saturating_sub(1)), new.1.min(body.paragraphs.get(new.0).map(|x| x.char_len()).unwrap_or(0)));
|
||||
s.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = new;
|
||||
tt.line_end = if upstream { Some(new) } else { None };
|
||||
if !extend {
|
||||
tt.anchor = new;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||
}
|
||||
|
||||
pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = pos_param(p, "anchor").ok_or_else(|| bad("text.select", "missing `anchor`"))?;
|
||||
let c = pos_param(p, "caret").unwrap_or(a);
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = a;
|
||||
t.caret = c;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectAll", "not editing text"))?;
|
||||
let end = body_of(st, &t).map(ed::end).unwrap_or((0, 0));
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (0, 0);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn select_word(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectWord", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let body = body_of(st, &t).cloned().unwrap_or_default();
|
||||
let (a, b) = ed::word_at(&body, at);
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = a;
|
||||
t.caret = b;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let body = body_of(st, &t);
|
||||
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
|
||||
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them.
|
||||
/// Returns whether any tab was found.
|
||||
pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool {
|
||||
let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0);
|
||||
if tabs == 0 {
|
||||
return false;
|
||||
}
|
||||
if let Some(r) = para.runs.first_mut() {
|
||||
r.text = r.text.trim_start_matches('\t').to_string();
|
||||
}
|
||||
para.level = tabs.min(8) as u8;
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string();
|
||||
let id = match id_param(p, "id", "text.set")? {
|
||||
Some(i) => i,
|
||||
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?,
|
||||
};
|
||||
let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)));
|
||||
let inherited = inherited_aligns(s, id);
|
||||
edit_shapes(s, &json!({"id": id}), "text.set", |sh| {
|
||||
let target = match (&mut sh.kind, cell) {
|
||||
(ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text),
|
||||
(ShapeKind::Table(_), None) => None,
|
||||
_ => Some(sh.text.get_or_insert_with(TextBody::default)),
|
||||
};
|
||||
let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) };
|
||||
let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect();
|
||||
let mut nb = TextBody::from_text(&text);
|
||||
for (i, para) in nb.paragraphs.iter_mut().enumerate() {
|
||||
// Leading tabs set the outline level.
|
||||
if !apply_leading_tabs(para)
|
||||
&& let Some((lv, pp)) = levels.get(i)
|
||||
{
|
||||
para.level = *lv;
|
||||
para.props = pp.clone();
|
||||
}
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
for r in &mut para.runs {
|
||||
r.props = keep.clone();
|
||||
}
|
||||
}
|
||||
body.paragraphs = nb.paragraphs;
|
||||
Ok(())
|
||||
})?;
|
||||
fit_text_box(s, id)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id", "text.get")? {
|
||||
Some(i) => i,
|
||||
None => match &st.selection.text {
|
||||
Some(t) => t.shape,
|
||||
None => st.selection.shapes.first().copied().ok_or_else(|| bad("text.get", "missing `id`"))?,
|
||||
},
|
||||
};
|
||||
let sh = st.shape(id).ok_or_else(|| bad("text.get", "no such shape"))?;
|
||||
let t = sh.text.clone().unwrap_or_default();
|
||||
Ok(json!({"text": t.text(), "paragraphs": t.paragraphs.iter().map(|p| json!({"level": p.level, "text": p.text()})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_paragraph_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn select_paragraph_huge_at() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
|
||||
s.execute("text.edit", &json!({})).unwrap();
|
||||
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
// Clamped to the last (only) paragraph, never past the end of the body.
|
||||
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user