mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528a28d2fc | ||
|
|
222ef368f4 | ||
|
|
d318583867 | ||
|
|
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 | ||
|
|
57acc28df3 | ||
|
|
c0128a0eb8 | ||
|
|
0ce545fcb8 |
+1
-1
@@ -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 |
|
||||
|
||||
Generated
+503
-11
@@ -440,6 +440,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -467,6 +473,15 @@ version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -692,6 +707,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -705,7 +730,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -718,7 +743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -777,6 +802,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
@@ -794,6 +828,31 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"verifysign",
|
||||
"winreg",
|
||||
"winsafe",
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -843,6 +902,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -862,6 +931,7 @@ dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"craft-update",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-media",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -908,7 +978,7 @@ dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
"serde_json",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -941,7 +1011,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -964,7 +1034,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1092,10 +1162,10 @@ dependencies = [
|
||||
"deckcraft-model",
|
||||
"image",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,6 +1205,7 @@ dependencies = [
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-update",
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1180,6 +1251,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.5.0"
|
||||
@@ -1204,6 +1281,16 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1263,6 +1350,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.37.0"
|
||||
@@ -1571,6 +1664,16 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1735,6 +1838,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1754,6 +1867,17 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1773,8 +1897,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2028,6 +2154,22 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "i_float"
|
||||
version = "5.0.0"
|
||||
@@ -2404,7 +2546,7 @@ name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
@@ -2672,6 +2814,18 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -2696,6 +2850,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -3109,6 +3269,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -3154,7 +3320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3320,6 +3486,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -3392,6 +3571,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3443,6 +3628,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3611,6 +3805,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -3658,6 +3866,80 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3697,6 +3979,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -3709,6 +4000,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -3720,6 +4034,10 @@ name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
@@ -3790,6 +4108,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3961,6 +4290,12 @@ dependencies = [
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
@@ -4189,6 +4524,16 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -4274,6 +4619,36 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.12.0"
|
||||
@@ -4415,6 +4790,18 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -4486,6 +4873,41 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4498,6 +4920,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -4548,6 +4976,16 @@ dependencies = [
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "verifysign"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -4564,6 +5002,12 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -4761,7 +5205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -4813,6 +5257,15 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -5395,7 +5848,7 @@ dependencies = [
|
||||
"calloop",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -5440,6 +5893,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
@@ -5450,6 +5913,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
@@ -5739,6 +6208,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -5786,6 +6261,23 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -61,6 +61,7 @@ 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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
+12
@@ -3,9 +3,12 @@
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **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-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-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
|
||||
@@ -136,7 +139,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|---|---|
|
||||
| 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) |
|
||||
@@ -146,14 +151,17 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 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-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) |
|
||||
@@ -286,12 +294,15 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 +310,7 @@ 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-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 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -187,6 +187,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(&craft_update::suite::DECKCRAFT, &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 +268,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();
|
||||
|
||||
@@ -62,3 +62,6 @@ sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Katacc]
|
||||
sync_github_name = true
|
||||
|
||||
+2
-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)]
|
||||
|
||||
@@ -466,6 +466,9 @@ impl Decoder {
|
||||
let mut r = BitReader::new(payload);
|
||||
let shown = self.frame_header(&mut r, temporal_id, spatial_id)?;
|
||||
if let Some(o) = shown {
|
||||
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
|
||||
return Err(Error::Invalid("too many frame outputs in chunk"));
|
||||
}
|
||||
out.push((o, tu, spatial_id));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -906,6 +906,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 +926,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;
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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::{CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, str_param, targets, usize_param};
|
||||
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> {
|
||||
@@ -230,7 +232,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
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 +249,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 +265,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"))?);
|
||||
@@ -567,4 +569,25 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, de
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, always, bad, bool_or, bool_param, cmd, color_param, color_value, f64_param, has_doc, has_slide, ok, rect_param,
|
||||
str_param, usize_param, with_param,
|
||||
CommandSpec, NO_ID_SPACE, always, bad, bool_or, bool_param, cmd, color_param, color_value, f64_param, has_doc, has_slide, ok, opt_usize_param,
|
||||
rect_param, str_param, usize_param, with_param,
|
||||
};
|
||||
use crate::{Result, Session, Target};
|
||||
|
||||
@@ -1347,17 +1347,17 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn layout_target(s: &Session, p: &Value) -> Result<(usize, usize)> {
|
||||
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((usize_param(p, "master").unwrap_or(m), usize_param(p, "layout").unwrap_or(l)))
|
||||
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
|
||||
}
|
||||
|
||||
fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mi, li) = layout_target(s, p)?;
|
||||
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 =
|
||||
@@ -1369,7 +1369,7 @@ fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mi, li) = layout_target(s, p)?;
|
||||
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) {
|
||||
@@ -1622,6 +1622,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
|
||||
@@ -321,7 +321,18 @@ 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 = 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);
|
||||
}
|
||||
taken.insert(sh.name.clone());
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
|
||||
@@ -6,6 +6,7 @@ use deckcraft_model::resolve;
|
||||
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
use super::{
|
||||
@@ -64,6 +65,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_or_shapes,
|
||||
language
|
||||
),
|
||||
cmd!(
|
||||
"format.languageAll",
|
||||
"Language for All Text",
|
||||
["Review", "Language"],
|
||||
None,
|
||||
"{lang?, noProof?: bool} (proofing language of every text in the presentation: slides, tables, notes, masters and layouts, and the default for new text; one undo step)",
|
||||
has_doc,
|
||||
language_all
|
||||
),
|
||||
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
||||
cmd!(
|
||||
"format.case",
|
||||
@@ -410,16 +420,17 @@ fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
r.fill = Some(f);
|
||||
})
|
||||
}
|
||||
fn language(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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("format.language", "give `lang` or `noProof`"));
|
||||
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("format.language", "`lang` is a language tag such as en-US"));
|
||||
return Err(bad(id, "`lang` is a language tag such as en-US"));
|
||||
}
|
||||
format_run(s, &move |r| {
|
||||
Ok(move |r: &mut RunProps| {
|
||||
if let Some(l) = &lang {
|
||||
r.lang = Some(l.clone());
|
||||
}
|
||||
@@ -428,6 +439,61 @@ fn language(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Review › Language on the selected text (or the selected shapes' text).
|
||||
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.
|
||||
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()
|
||||
}
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
|
||||
@@ -152,27 +152,38 @@ fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm {
|
||||
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
|
||||
}
|
||||
|
||||
/// The word an automatic shape name starts with ("Rectangle" in "Rectangle 3").
|
||||
pub(crate) fn auto_name_base(shape: &Shape) -> String {
|
||||
match &shape.kind {
|
||||
ShapeKind::Picture { .. } => "Picture".to_string(),
|
||||
ShapeKind::Table(_) => "Table".to_string(),
|
||||
ShapeKind::Chart(_) => "Chart".to_string(),
|
||||
ShapeKind::Media(m) if m.video => "Video".to_string(),
|
||||
ShapeKind::Media(_) => "Audio".to_string(),
|
||||
ShapeKind::Model3d { .. } => "3D Model".to_string(),
|
||||
_ if shape.text_box => "TextBox".to_string(),
|
||||
_ => shape
|
||||
.geom
|
||||
.preset_name()
|
||||
.and_then(deckcraft_geom::preset::info)
|
||||
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
|
||||
.unwrap_or_else(|| "Shape".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses,
|
||||
/// so a gap left by a deleted shape never makes a second shape with the same name.
|
||||
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>) -> String {
|
||||
(taken.len() + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
|
||||
s.edit(|doc, sel| {
|
||||
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
if shape.name.is_empty() {
|
||||
let base = match &shape.kind {
|
||||
ShapeKind::Picture { .. } => "Picture".to_string(),
|
||||
ShapeKind::Table(_) => "Table".to_string(),
|
||||
ShapeKind::Chart(_) => "Chart".to_string(),
|
||||
ShapeKind::Media(m) if m.video => "Video".to_string(),
|
||||
ShapeKind::Media(_) => "Audio".to_string(),
|
||||
ShapeKind::Model3d { .. } => "3D Model".to_string(),
|
||||
_ if shape.text_box => "TextBox".to_string(),
|
||||
_ => shape
|
||||
.geom
|
||||
.preset_name()
|
||||
.and_then(deckcraft_geom::preset::info)
|
||||
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
|
||||
.unwrap_or_else(|| "Shape".into()),
|
||||
};
|
||||
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1;
|
||||
shape.name = format!("{base} {n}");
|
||||
let base = auto_name_base(&shape);
|
||||
let taken = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken);
|
||||
}
|
||||
let id = shape.id;
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
|
||||
@@ -1281,14 +1292,25 @@ fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
|
||||
}
|
||||
Err(_) => (0, 0, 0, false),
|
||||
};
|
||||
let warning = (!playable).then(|| 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})"),
|
||||
});
|
||||
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 { super::media::poster_png(&bytes, 0) } else { None };
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| {
|
||||
|
||||
@@ -290,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
|
||||
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -432,7 +432,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()?;
|
||||
@@ -845,3 +845,18 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,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 +308,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 +350,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 +368,14 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 +383,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 +403,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");
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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),
|
||||
@@ -303,6 +304,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 +370,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}))
|
||||
}
|
||||
@@ -697,4 +740,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,30 +5,48 @@ use deckcraft_model::{Shape, ShapeKind, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, bad, bool_or, bool_param, cmd, color_param, edit_shapes, f64_param, has_selection, has_slide, id_param, ok, str_param,
|
||||
table_drawn_heights, usize_param, xfrm_of,
|
||||
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!(
|
||||
@@ -228,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> {
|
||||
@@ -285,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)),
|
||||
@@ -296,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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,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,
|
||||
@@ -322,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")) })
|
||||
}
|
||||
|
||||
@@ -536,7 +567,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| {
|
||||
@@ -565,7 +596,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) {
|
||||
@@ -580,8 +611,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];
|
||||
|
||||
@@ -111,7 +111,11 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
match p.get("after") {
|
||||
Some(Value::Null) => t.advance_after_ms = None,
|
||||
Some(v) => t.advance_after_ms = v.as_u64().map(|x| x.min(86_400_000) as u32),
|
||||
Some(v) => {
|
||||
if let Some(x) = v.as_u64() {
|
||||
t.advance_after_ms = Some(x.min(86_400_000) as u32);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,11 @@ pub struct Selection {
|
||||
pub text: Option<TextSel>,
|
||||
/// Selected table cells: (table, (r0, c0), (r1, c1)).
|
||||
pub cells: Option<(ShapeId, (usize, usize), (usize, usize))>,
|
||||
/// Slide-list range last made with Shift-click: `(anchor, end)`. A further Shift-click extends from
|
||||
/// the anchor while the current slide is still `end`; moving to another slide drops it. Held as
|
||||
/// slide ids, so moving, inserting or deleting slides can't make it name other slides.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub range_anchor: Option<(SlideId, SlideId)>,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
@@ -353,6 +358,9 @@ pub struct Prefs {
|
||||
pub undo_limit: usize,
|
||||
/// Minutes between AutoRecover saves of unsaved presentations.
|
||||
pub recovery_minutes: f64,
|
||||
/// The unit the rulers, the Format pane and the size dialogs show (Preferences); the deck
|
||||
/// itself always stores points.
|
||||
pub units: deckcraft_geom::Unit,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -376,6 +384,7 @@ impl Default for Prefs {
|
||||
author: "Presenter".into(),
|
||||
undo_limit: 500,
|
||||
recovery_minutes: 1.0,
|
||||
units: deckcraft_geom::Unit::Inches,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,9 @@ impl Dictionary {
|
||||
|
||||
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
|
||||
pub fn check(&self, word: &str) -> bool {
|
||||
if word.contains('\u{2019}') {
|
||||
return self.check(&word.replace('\u{2019}', "'"));
|
||||
}
|
||||
if word.is_empty() || word.chars().all(|c| !c.is_alphabetic()) {
|
||||
return true;
|
||||
}
|
||||
@@ -800,6 +803,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curly_apostrophe_matches_ascii_entry() {
|
||||
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
|
||||
assert!(d.check("don't"));
|
||||
assert!(d.check("don\u{2019}t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_affixes_and_cross_products_check() {
|
||||
let d = d();
|
||||
|
||||
@@ -214,6 +214,77 @@ fn clipboard_copy_paste_shapes_and_slides() {
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_shapes_get_the_next_automatic_name() {
|
||||
let mut s = session();
|
||||
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
let before = names(&s).len();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
|
||||
let auto = names(&s)[before].clone();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let all = names(&s);
|
||||
let base = auto.rsplit_once(' ').unwrap().0;
|
||||
assert_eq!(all[before + 1], format!("{base} {}", before + 2));
|
||||
assert_eq!(all[before + 2], format!("{base} {}", before + 3));
|
||||
|
||||
// A name the user chose is not an automatic one and stays on the copy.
|
||||
let id = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": "Hero"})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(names(&s).last().unwrap(), "Hero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_after_a_deleted_shape_does_not_repeat_a_name() {
|
||||
let mut s = session();
|
||||
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
let mut ids = vec![];
|
||||
for _ in 0..3 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("edit.delete", &json!({"ids": [ids[1]]})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
s.doc_mut().unwrap().selection.shapes = vec![deckcraft_model::ShapeId(ids[2])];
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let all = names(&s);
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_on_another_slide_keeps_names_so_morph_still_pairs_them() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 20, 20]})).unwrap();
|
||||
let first = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 200, 80, 80]})).unwrap();
|
||||
s.execute("edit.delete", &json!({"ids": [first]})).unwrap();
|
||||
s.execute("edit.selectAll", &json!({})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let (a, b) = (&st.doc.slides[1], &st.doc.slides[2]);
|
||||
let an: Vec<_> = a.shapes.iter().map(|x| x.name.clone()).collect();
|
||||
let bn: Vec<_> = b.shapes.iter().map(|x| x.name.clone()).collect();
|
||||
assert_eq!(an, bn);
|
||||
let pairs = deckcraft_anim::morph_pairs(a, b);
|
||||
assert_eq!(pairs.len(), 2);
|
||||
for (ia, ib) in pairs {
|
||||
let na = &a.shapes.iter().find(|x| x.id == ia).unwrap().name;
|
||||
let nb = &b.shapes.iter().find(|x| x.id == ib).unwrap().name;
|
||||
assert_eq!(na, nb);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_copy_is_html_and_pages_html_pastes_bold() {
|
||||
let mut s = session();
|
||||
@@ -4475,6 +4546,64 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
|
||||
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
|
||||
}
|
||||
|
||||
/// Review › Language › Whole presentation: every run, placeholder list style and default text style
|
||||
/// takes the language, with nothing selected, in one undo step.
|
||||
#[test]
|
||||
fn language_all_marks_every_text_in_the_deck_with_nothing_selected() {
|
||||
crate::spell::install("xx-AL", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n"));
|
||||
let mut s = session();
|
||||
let a =
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello wurld"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("format.language", &json!({"lang": "de-DE"})).unwrap();
|
||||
s.execute("slide.new", &json!({"title": "world hellow"})).unwrap();
|
||||
s.execute("insert.table", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
// A layout placeholder with its own list style (a:lstStyle) in another language.
|
||||
let placeholder = s
|
||||
.edit(|doc, _| {
|
||||
let m = std::sync::Arc::make_mut(doc.masters.first_mut().unwrap());
|
||||
let sh = m.layouts[0].shapes.iter_mut().find(|sh| sh.text.is_some()).unwrap();
|
||||
let lv = deckcraft_model::text::LevelStyle {
|
||||
run: deckcraft_model::RunProps { lang: Some("de-DE".into()), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
sh.text.as_mut().unwrap().list_style.set(0, lv);
|
||||
Ok(sh.id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("format.languageAll", &json!({"lang": "xx-AL"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let ph = deckcraft_model::find_shape(&st.doc.masters[0].layouts[0].shapes, placeholder).unwrap();
|
||||
assert_eq!(ph.text.as_ref().unwrap().list_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
|
||||
// A body without a list style doesn't get one.
|
||||
let boxed = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
|
||||
assert!(boxed.text.as_ref().unwrap().list_style.is_empty());
|
||||
let mut langs = vec![];
|
||||
for sl in &st.doc.slides {
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
langs.extend(sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.lang.clone()));
|
||||
if let deckcraft_model::ShapeKind::Table(t) = &sh.kind {
|
||||
langs.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.text.paragraphs).map(|p| p.end_props.lang.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(!langs.is_empty() && langs.iter().all(|l| l.as_deref() == Some("xx-AL")), "{langs:?}");
|
||||
// The defaults new text inherits.
|
||||
assert_eq!(st.doc.default_text_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
|
||||
assert!(st.doc.masters.iter().all(|m| m.body_style.level(0).is_some_and(|l| l.run.lang.as_deref() == Some("xx-AL"))));
|
||||
let words: Vec<String> =
|
||||
s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().iter().map(|w| w["word"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(words, vec!["wurld", "hellow"]);
|
||||
// One undo step puts the per-box language back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
|
||||
assert_eq!(sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.lang.as_deref(), Some("de-DE"));
|
||||
assert!(s.execute("format.languageAll", &json!({})).is_err());
|
||||
assert!(s.execute("format.languageAll", &json!({"lang": "en US!"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caret_takes_the_colour_of_the_text() {
|
||||
let caret = |theme: &str| {
|
||||
@@ -4883,6 +5012,55 @@ fn paragraph_commands_clamp_a_direct_hostile_text_selection() {
|
||||
assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_col_commands_reject_an_invalid_index() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"], ["e", "f"]]));
|
||||
for (cmd, key) in [
|
||||
("table.deleteRow", "row"),
|
||||
("table.deleteColumn", "col"),
|
||||
("table.insertRowAbove", "row"),
|
||||
("table.insertColumnLeft", "col"),
|
||||
("table.split", "row"),
|
||||
] {
|
||||
for bad in [json!(-1), json!(1.5), json!("1")] {
|
||||
let r = s.execute(cmd, &json!({"id": id.0, key: bad}));
|
||||
assert!(r.is_err(), "{cmd} {key}={bad} should be rejected, got {r:?}");
|
||||
let t = table_of(&s, id);
|
||||
assert_eq!((t.n_rows(), t.n_cols()), (3, 2), "{cmd} {key}={bad} must leave the table unchanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_commands_reject_an_invalid_cell_and_name_the_registered_command() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"], ["e", "f"]]));
|
||||
s.execute("table.selectCells", &json!({"id": id.0, "from": [0, 0], "to": [1, 1]})).unwrap();
|
||||
for (cmd, params) in [
|
||||
("table.split", json!({"id": id.0, "cell": [-1, 0]})),
|
||||
("table.merge", json!({"id": id.0, "from": [-1, 0], "to": [1, 1]})),
|
||||
("table.merge", json!({"id": id.0, "from": [0, 0], "to": "x"})),
|
||||
("table.rowHeight", json!({"id": id.0, "row": -1, "height": 50.0})),
|
||||
("table.rowHeight", json!({"id": id.0, "row": 1.5, "height": 50.0})),
|
||||
("table.columnWidth", json!({"id": id.0, "col": -1, "width": 50.0})),
|
||||
("table.columnWidth", json!({"id": id.0, "col": "1", "width": 50.0, "keepTotal": true})),
|
||||
] {
|
||||
let before = table_of(&s, id);
|
||||
let (rows, cols) = (before.rows.iter().map(|r| r.height).collect::<Vec<_>>(), before.cols.clone());
|
||||
let r = s.execute(cmd, ¶ms);
|
||||
assert!(r.is_err(), "{cmd} {params} should be rejected, got {r:?}");
|
||||
let t = table_of(&s, id);
|
||||
assert_eq!((t.n_rows(), t.n_cols()), (3, 2), "{cmd} {params} must leave the table unchanged");
|
||||
assert_eq!(t.rows.iter().map(|r| r.height).collect::<Vec<_>>(), rows, "{cmd} {params} must not change row heights");
|
||||
assert_eq!(t.cols, cols, "{cmd} {params} must not change column widths");
|
||||
}
|
||||
let e = s.execute("table.deleteRow", &json!({"id": id.0, "row": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("table.deleteRow"), "error should name the registered command: {e}");
|
||||
let e = s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("table.insertColumnLeft"), "error should name the registered command: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_column_width_of_a_huge_column_is_ignored() {
|
||||
let mut s = session();
|
||||
@@ -5443,3 +5621,28 @@ fn slide_hide_and_layout_reject_invalid_index() {
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_timing_invalid_after_keeps_value() {
|
||||
let mut s = session();
|
||||
let after = |s: &Session| s.doc().unwrap().doc.slides[0].transition.as_ref().and_then(|t| t.advance_after_ms);
|
||||
s.execute("transition.timing", &json!({"after": 5000})).unwrap();
|
||||
assert_eq!(after(&s), Some(5000));
|
||||
for bad in [json!(-5), json!(1.5), json!("abc")] {
|
||||
let _ = s.execute("transition.timing", &json!({"after": bad}));
|
||||
assert_eq!(after(&s), Some(5000), "after:{bad} must not clear the auto-advance");
|
||||
}
|
||||
s.execute("transition.timing", &json!({"after": null})).unwrap();
|
||||
assert_eq!(after(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ids_array_never_falls_back_to_the_selection() {
|
||||
let mut s = session();
|
||||
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
for ids in [json!([-1]), json!([1.5]), json!([4294967296u64]), json!([-1, a])] {
|
||||
let r = s.execute("edit.delete", &json!({ "ids": ids }));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "invalid ids {ids} gave {r:?}");
|
||||
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,17 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
for s in &p.slides {
|
||||
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
|
||||
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
|
||||
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
|
||||
Some(tp) => runs(&mut out, tp),
|
||||
None => escape(&mut out, &s.title()),
|
||||
// The title stays one outline paragraph (a `\par` would start another slide on re-import),
|
||||
// so its paragraphs are joined with line breaks.
|
||||
let mut tps = title.into_iter().flat_map(|t| &t.paragraphs).filter(|p| !p.is_empty()).peekable();
|
||||
if tps.peek().is_none() {
|
||||
escape(&mut out, &s.title());
|
||||
}
|
||||
for (k, tp) in tps.enumerate() {
|
||||
if k > 0 {
|
||||
out.push_str("\\line ");
|
||||
}
|
||||
runs(&mut out, tp);
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
@@ -104,6 +112,12 @@ const SKIP: &[&str] = &[
|
||||
"rsidtbl",
|
||||
"object",
|
||||
"fldinst",
|
||||
// A list item's rendered bullet or number, written before its text (Word: listtext; RTF 1.5 and
|
||||
// older writers: pntext, pntxta, pntxtb). Its `\pard` must not reset the paragraph's indent.
|
||||
"listtext",
|
||||
"pntext",
|
||||
"pntxta",
|
||||
"pntxtb",
|
||||
];
|
||||
|
||||
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
|
||||
@@ -223,7 +237,7 @@ pub fn rtf_to_outline(rtf: &str) -> String {
|
||||
_ => {}
|
||||
}
|
||||
} else if n == b'\'' {
|
||||
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
let hex = rtf.get(i + 1..i + 3).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
i = advance(rtf, i, 3);
|
||||
if !st.skip
|
||||
&& let Some(h) = hex
|
||||
@@ -239,6 +253,7 @@ pub fn rtf_to_outline(rtf: &str) -> String {
|
||||
match n {
|
||||
b'\\' | b'{' | b'}' => line.push(n as char),
|
||||
b'~' => line.push('\u{a0}'),
|
||||
b'_' => line.push('\u{2011}'),
|
||||
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
|
||||
_ => {}
|
||||
}
|
||||
@@ -318,6 +333,12 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
/// A sign in a `\'hh` escape is not a hex digit and must be ignored.
|
||||
#[test]
|
||||
fn hex_escape_rejects_sign() {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 a\'+5b\par}"), "ab\n");
|
||||
}
|
||||
|
||||
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
|
||||
#[test]
|
||||
fn non_ascii_text_after_escapes_does_not_panic() {
|
||||
@@ -337,6 +358,12 @@ mod tests {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
|
||||
}
|
||||
|
||||
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
|
||||
#[test]
|
||||
fn non_breaking_hyphen_is_kept() {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1\pard Year\_end review\par}"), "Year\u{2011}end review\n");
|
||||
}
|
||||
|
||||
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
|
||||
#[test]
|
||||
fn huge_left_indent_gives_the_deepest_level() {
|
||||
@@ -355,9 +382,30 @@ mod tests {
|
||||
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
|
||||
}
|
||||
|
||||
/// A title with two paragraphs: the plain outline keeps both, the RTF export kept only the first.
|
||||
#[test]
|
||||
fn every_title_paragraph_is_exported() {
|
||||
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
|
||||
crate::outline_to_slides(&mut p, "First line\n\tBody\n");
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let title = slide.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_mut()).unwrap();
|
||||
title.paragraphs.push(Paragraph::new("Second line"));
|
||||
let rtf = slides_to_rtf(&p);
|
||||
assert!(rtf.contains("First line\\line Second line\\b0\\par"), "{rtf}");
|
||||
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
|
||||
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
|
||||
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
|
||||
}
|
||||
|
||||
/// Word writes a list item's bullet as `{\\listtext\\pard\\plain …\\'b7\\tab}` (older writers:
|
||||
/// `{\\pntext …}`) after the paragraph's own `\\li`. That group is the rendered bullet, not text.
|
||||
#[test]
|
||||
fn word_list_bullets_are_not_text_and_keep_the_indent() {
|
||||
let rtf = r"{\rtf1\ansi\pard Title\par\pard\fi-360\li720{\listtext\pard\plain\f1\'b7\tab}Point\par\pard\li720{\pntext\f1\'b7\tab}Old\par}";
|
||||
assert_eq!(rtf_to_outline(rtf), "Title\n\t\tPoint\n\t\tOld\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1334,7 +1334,9 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
let (y1, y2) = (h / 2.0 - h * k / 2.0, h / 2.0 + h * k / 2.0);
|
||||
let dx = (ss * a(1).max(0.0) / 1e5).min(w);
|
||||
if name == "notchedRightArrow" {
|
||||
let n = dx * (k / 2.0) * 0.9;
|
||||
// x1 = dy1 · dx2 / hd2 in presetShapeDefinitions, with dy1 = h·k/2: the notch's
|
||||
// edges run parallel to the head's
|
||||
let n = dx * k;
|
||||
let pts = [(0.0, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (0.0, y2), (n, h / 2.0)];
|
||||
g.paths = vec![fill(Pb::new().poly(&pts).take())];
|
||||
} else {
|
||||
@@ -1860,10 +1862,10 @@ fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
|
||||
let r = r.min(w / 2.0).min(h / 2.0);
|
||||
// Walk clockwise from the top-left; insert the wedge on the edge facing the tip.
|
||||
let horiz = dx.abs() > dy.abs();
|
||||
let (bx0, bx1) = (w * 5.0 / 12.0, w * 7.0 / 12.0);
|
||||
let (by0, by1) = (h * 5.0 / 12.0, h * 7.0 / 12.0);
|
||||
let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (bx0 + w / 12.0 * 2.0 - w / 12.0 * 2.0, bx1 + w * 2.0 / 12.0) };
|
||||
let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (by0, by1 + h * 2.0 / 12.0) };
|
||||
// The wedge's base: 2..5 twelfths of the edge on the tip's side of the centre when it is
|
||||
// before it, 7..10 twelfths when after (presetShapeDefinitions' xg1/xg2, yg1/yg2).
|
||||
let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (w * 7.0 / 12.0, w * 10.0 / 12.0) };
|
||||
let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (h * 7.0 / 12.0, h * 10.0 / 12.0) };
|
||||
b.m(r, 0.0);
|
||||
if !inside && !horiz && dy < 0.0 {
|
||||
b.l(bx0, 0.0).l(tip.x, tip.y).l(bx1, 0.0);
|
||||
@@ -2454,6 +2456,20 @@ mod tests {
|
||||
assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notched_arrow_notch_parallels_the_head() {
|
||||
// presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run
|
||||
// parallel to the head's (25 on a 200 × 100 arrow at the default adjusts).
|
||||
for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] {
|
||||
let g = build("notchedRightArrow", w, h, &adj).unwrap();
|
||||
let notch = g.paths[0].path.elements().iter().find_map(|e| match e {
|
||||
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p),
|
||||
_ => None,
|
||||
});
|
||||
assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equation_shapes_match_powerpoints_outlines() {
|
||||
// Corners read off PowerPoint's own outlines of the presets at their default adjusts.
|
||||
@@ -2590,4 +2606,46 @@ mod tests {
|
||||
assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined");
|
||||
}
|
||||
}
|
||||
|
||||
/// The end points of a path's move/line elements, in order.
|
||||
fn vertices(p: &BezPath) -> Vec<Point> {
|
||||
p.elements()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wedge_callout_mirrors_with_its_tip() {
|
||||
// A tip below and right of the centre draws the mirror image of the same tip on the left;
|
||||
// the same for a tip right of the centre, above or below it. presetShapeDefinitions puts
|
||||
// the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at
|
||||
// 7..10 twelfths on the far side.
|
||||
let (w, h) = (240.0, 120.0);
|
||||
// Sorted and deduplicated: the outline closes on the corner it starts from, which the
|
||||
// mirror moves to the opposite corner.
|
||||
let sorted = |mut v: Vec<Point>| {
|
||||
v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9);
|
||||
v
|
||||
};
|
||||
for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] {
|
||||
let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path);
|
||||
let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path);
|
||||
let b: Vec<Point> = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect();
|
||||
let (a, b) = (sorted(a), sorted(b));
|
||||
assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}");
|
||||
for (p, q) in a.iter().zip(&b) {
|
||||
assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}");
|
||||
}
|
||||
}
|
||||
// The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width.
|
||||
let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path);
|
||||
for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] {
|
||||
assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -327,12 +327,18 @@ impl Dpb {
|
||||
had5 = true;
|
||||
}
|
||||
6 => {
|
||||
for e in &mut self.entries {
|
||||
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
|
||||
e.mark = RefMark::Unused;
|
||||
let allowed = match self.max_long_term_frame_idx {
|
||||
Some(max) => m.long_term_frame_idx <= max,
|
||||
None => false,
|
||||
};
|
||||
if allowed {
|
||||
for e in &mut self.entries {
|
||||
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
|
||||
e.mark = RefMark::Unused;
|
||||
}
|
||||
}
|
||||
*cur_long = Some(m.long_term_frame_idx);
|
||||
}
|
||||
*cur_long = Some(m.long_term_frame_idx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -410,6 +416,11 @@ impl Dpb {
|
||||
} else {
|
||||
while self.entries.len() >= self.capacity {
|
||||
if !self.bump(out) {
|
||||
if let Some(pos) = self.entries.iter().position(|e| !e.needed_for_output) {
|
||||
self.entries.remove(pos);
|
||||
} else {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +91,21 @@ fn clip_u8(v: i32) -> u8 {
|
||||
v.clamp(0, 255) as u8
|
||||
}
|
||||
|
||||
/// A dequantised coefficient the inverse transforms are defined for: the dequantisation wraps
|
||||
/// by design (see [`huge_levels_dequantise_without_overflow_panics`]), so their butterflies
|
||||
/// clamp an r outside −2^15..2^15−1 to this range — where the spec leaves the output
|
||||
/// unspecified anyway (8.5.12.1, 8.5.13.1) — instead of overflowing the plain i32 additions.
|
||||
#[inline(always)]
|
||||
fn r_clamp(v: i32) -> i32 {
|
||||
v.clamp(-(1 << 15), (1 << 15) - 1)
|
||||
}
|
||||
|
||||
/// Inverse 4x4 transform of raster-order `d` and add to `dst` (8.5.12.2, 8.5.14).
|
||||
///
|
||||
/// Rows first, then columns (the spec's order: the `>> 1` steps make it matter). The column
|
||||
/// pass and the reconstruction run across all columns at once so they vectorise.
|
||||
pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
|
||||
let d: [i32; 16] = std::array::from_fn(|i| r_clamp(d[i]));
|
||||
let mut t = [[0i32; 4]; 4];
|
||||
for (i, row) in t.iter_mut().enumerate() {
|
||||
let r = &d[i * 4..i * 4 + 4];
|
||||
@@ -161,6 +171,7 @@ fn idct8_1d(d: [i32; 8]) -> [i32; 8] {
|
||||
/// Inverse 8x8 transform of raster-order `d` and add to `dst` (8.5.13.2): rows, then columns,
|
||||
/// the column pass and reconstruction across all columns at once (they vectorise).
|
||||
pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
|
||||
let d: [i32; 64] = std::array::from_fn(|i| r_clamp(d[i]));
|
||||
let mut t = [[0i32; 8]; 8];
|
||||
for (row, src) in t.iter_mut().zip(d.as_chunks::<8>().0) {
|
||||
*row = idct8_1d(*src);
|
||||
@@ -304,4 +315,32 @@ mod tests {
|
||||
let _ = scale8(i32::MIN, 16 * 25, 51);
|
||||
let _ = scale8(i32::MIN, 16 * 25, 0);
|
||||
}
|
||||
|
||||
/// The same wrapping dequantisation hands the inverse transforms any i32 coefficient, and
|
||||
/// their butterflies add in plain i32: `i32::MAX + i32::MAX` panicked a debug build. The
|
||||
/// spec leaves the transforms' output unspecified when a dequantised value falls outside
|
||||
/// −2^15..2^15−1 (8.5.12.1, 8.5.13.1), so those inputs are clamped to that range —
|
||||
/// bit-exact for every stream inside it — instead of panicking.
|
||||
#[test]
|
||||
fn huge_coefficients_are_clamped_to_the_spec_r_range() {
|
||||
assert_eq!(r_clamp(-32769), -32768);
|
||||
assert_eq!(r_clamp(-32768), -32768);
|
||||
assert_eq!(r_clamp(32767), 32767);
|
||||
assert_eq!(r_clamp(32768), 32767);
|
||||
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1);
|
||||
for d in [[i32::MAX; 16], [i32::MIN; 16], std::array::from_fn(|i| if i % 2 == 0 { i32::MIN } else { i32::MAX })] {
|
||||
let clamped = std::array::from_fn(|i| clamp(d[i]));
|
||||
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
|
||||
idct4_add(&d, &mut a, 8);
|
||||
idct4_add(&clamped, &mut b, 8);
|
||||
assert_eq!(a, b, "idct4 of extremes equals idct4 of the spec range");
|
||||
}
|
||||
for d in [[i32::MIN; 64], [i32::MAX; 64]] {
|
||||
let clamped = std::array::from_fn(|i| clamp(d[i]));
|
||||
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
|
||||
idct8_add(&d, &mut a, 8);
|
||||
idct8_add(&clamped, &mut b, 8);
|
||||
assert_eq!(a, b, "idct8 of extremes equals idct8 of the spec range");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,11 +440,22 @@ impl Decoder {
|
||||
let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma);
|
||||
let next_id = &mut self.next_id;
|
||||
let mut missing = 0u64;
|
||||
let max_missing = (sps.max_dec_pic_buffering as u64).clamp(1, 16);
|
||||
let mut shared_gray: Option<FrameRef> = None;
|
||||
let mut make_missing = |p: i32| -> FrameRef {
|
||||
missing += 1;
|
||||
if missing > max_missing
|
||||
&& let Some(ref g) = shared_gray
|
||||
{
|
||||
return g.clone();
|
||||
}
|
||||
let id = *next_id;
|
||||
*next_id += 1;
|
||||
Arc::new(Frame::gray(id, p, &template))
|
||||
let f = Arc::new(Frame::gray(id, p, &template));
|
||||
if shared_gray.is_none() {
|
||||
shared_gray = Some(f.clone());
|
||||
}
|
||||
f
|
||||
};
|
||||
let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing)?;
|
||||
if missing > 0 {
|
||||
|
||||
+137
-117
@@ -397,6 +397,37 @@ impl<'a> SliceDecoder<'a> {
|
||||
d.decode_data()
|
||||
}
|
||||
|
||||
/// Context initialisation / synchronisation at the start of a CTB (9.3.1, 9.3.2.1).
|
||||
fn init_ctb_contexts(&mut self, layout: &Layout, ts: u32, rs: u32, wctb: u32, first: bool) -> Result<()> {
|
||||
let ctb = 1i32 << self.sps.log2_ctb;
|
||||
let first_in_tile = ts == 0 || layout.tile_id[ts as usize] != layout.tile_id[ts as usize - 1];
|
||||
let wpp_row_start = self.pps.entropy_coding_sync
|
||||
&& (rs.is_multiple_of(wctb) || layout.tile_id[ts as usize] != layout.tile_id[layout.rs_to_ts[rs as usize - 1] as usize]);
|
||||
if first_in_tile {
|
||||
if !first {
|
||||
init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type);
|
||||
}
|
||||
self.last_qp = self.slice_qp;
|
||||
} else if wpp_row_start {
|
||||
let (x0, y0) = (self.ctb_x, self.ctb_y);
|
||||
let avail = self.pic.zavail(x0, y0, x0 + ctb, y0 - ctb, self.slice_addr);
|
||||
match (&self.pic.wpp_ctx, avail) {
|
||||
(Some(saved), true) => self.c.ctx = **saved,
|
||||
_ => init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type),
|
||||
}
|
||||
self.last_qp = self.slice_qp;
|
||||
} else if first && self.sh.dependent {
|
||||
match &self.pic.ds_ctx {
|
||||
Some((saved, qp)) => {
|
||||
self.c.ctx = **saved;
|
||||
self.last_qp = *qp;
|
||||
}
|
||||
None => return invalid("dependent slice segment without stored contexts"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_data(&mut self) -> Result<()> {
|
||||
let layout = self.pic.layout.clone();
|
||||
let wctb = self.pic.wctb as u32;
|
||||
@@ -412,32 +443,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
self.ctb_y = ry * ctb;
|
||||
self.pic.ctb_slice[rs as usize] = self.slice_idx;
|
||||
self.pic.ctb_addr[rs as usize] = self.slice_addr;
|
||||
// context initialisation / synchronisation (9.3.1, 9.3.2.1)
|
||||
let first_in_tile = ts == 0 || layout.tile_id[ts as usize] != layout.tile_id[ts as usize - 1];
|
||||
let wpp_row_start = self.pps.entropy_coding_sync
|
||||
&& (rs.is_multiple_of(wctb) || layout.tile_id[ts as usize] != layout.tile_id[layout.rs_to_ts[rs as usize - 1] as usize]);
|
||||
if first_in_tile {
|
||||
if !first {
|
||||
init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type);
|
||||
}
|
||||
self.last_qp = self.slice_qp;
|
||||
} else if wpp_row_start {
|
||||
let (x0, y0) = (self.ctb_x, self.ctb_y);
|
||||
let avail = self.pic.zavail(x0, y0, x0 + ctb, y0 - ctb, self.slice_addr);
|
||||
match (&self.pic.wpp_ctx, avail) {
|
||||
(Some(saved), true) => self.c.ctx = **saved,
|
||||
_ => init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type),
|
||||
}
|
||||
self.last_qp = self.slice_qp;
|
||||
} else if first && self.sh.dependent {
|
||||
match &self.pic.ds_ctx {
|
||||
Some((saved, qp)) => {
|
||||
self.c.ctx = **saved;
|
||||
self.last_qp = *qp;
|
||||
}
|
||||
None => return invalid("dependent slice segment without stored contexts"),
|
||||
}
|
||||
}
|
||||
self.init_ctb_contexts(&layout, ts, rs, wctb, first)?;
|
||||
first = false;
|
||||
if self.sh.sao_luma || self.sh.sao_chroma {
|
||||
self.parse_sao(rx, ry, rs)?;
|
||||
@@ -506,56 +512,60 @@ impl<'a> SliceDecoder<'a> {
|
||||
let mut p = SaoParams::default();
|
||||
for c in 0..3 {
|
||||
let enabled = if c == 0 { self.sh.sao_luma } else { self.sh.sao_chroma };
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
if c == 2 {
|
||||
p.type_idx[2] = p.type_idx[1];
|
||||
p.eo_class[2] = p.eo_class[1];
|
||||
} else {
|
||||
p.type_idx[c] = if self.dd(SAO_TYPE) == 0 {
|
||||
0
|
||||
} else if self.bypass() == 0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
}
|
||||
if p.type_idx[c] == 0 {
|
||||
continue;
|
||||
}
|
||||
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
|
||||
let cmax = (1u32 << (bd.min(10) - 5)) - 1;
|
||||
let mut abs = [0i16; 4];
|
||||
for a in abs.iter_mut() {
|
||||
let mut v = 0;
|
||||
while v < cmax && self.bypass() == 1 {
|
||||
v += 1;
|
||||
}
|
||||
*a = v as i16;
|
||||
}
|
||||
if p.type_idx[c] == 1 {
|
||||
for a in abs.iter_mut() {
|
||||
if *a != 0 && self.bypass() == 1 {
|
||||
*a = -*a;
|
||||
}
|
||||
}
|
||||
p.band_pos[c] = self.bypass_bits(5) as u8;
|
||||
p.offsets[c] = abs;
|
||||
} else {
|
||||
p.offsets[c] = [abs[0], abs[1], -abs[2], -abs[3]];
|
||||
if c == 0 {
|
||||
p.eo_class[0] = self.bypass_bits(2) as u8;
|
||||
}
|
||||
if c == 1 {
|
||||
p.eo_class[1] = self.bypass_bits(2) as u8;
|
||||
}
|
||||
if enabled {
|
||||
self.parse_sao_component(&mut p, c);
|
||||
}
|
||||
}
|
||||
self.pic.sao[rs as usize] = p;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SAO syntax of one enabled colour component `c` (7.3.8.3).
|
||||
fn parse_sao_component(&mut self, p: &mut SaoParams, c: usize) {
|
||||
if c == 2 {
|
||||
p.type_idx[2] = p.type_idx[1];
|
||||
p.eo_class[2] = p.eo_class[1];
|
||||
} else {
|
||||
p.type_idx[c] = if self.dd(SAO_TYPE) == 0 {
|
||||
0
|
||||
} else if self.bypass() == 0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
}
|
||||
if p.type_idx[c] == 0 {
|
||||
return;
|
||||
}
|
||||
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
|
||||
let cmax = (1u32 << (bd.min(10) - 5)) - 1;
|
||||
let mut abs = [0i16; 4];
|
||||
for a in abs.iter_mut() {
|
||||
let mut v = 0;
|
||||
while v < cmax && self.bypass() == 1 {
|
||||
v += 1;
|
||||
}
|
||||
*a = v as i16;
|
||||
}
|
||||
if p.type_idx[c] == 1 {
|
||||
for a in abs.iter_mut() {
|
||||
if *a != 0 && self.bypass() == 1 {
|
||||
*a = -*a;
|
||||
}
|
||||
}
|
||||
p.band_pos[c] = self.bypass_bits(5) as u8;
|
||||
p.offsets[c] = abs;
|
||||
} else {
|
||||
p.offsets[c] = [abs[0], abs[1], -abs[2], -abs[3]];
|
||||
if c == 0 {
|
||||
p.eo_class[0] = self.bypass_bits(2) as u8;
|
||||
}
|
||||
if c == 1 {
|
||||
p.eo_class[1] = self.bypass_bits(2) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn coding_quadtree(&mut self, x0: i32, y0: i32, log2: u32, depth: u32) -> Result<()> {
|
||||
let size = 1i32 << log2;
|
||||
let (w, h) = (self.pic.width as i32, self.pic.height as i32);
|
||||
@@ -826,6 +836,34 @@ impl<'a> SliceDecoder<'a> {
|
||||
self.c.init_engine()
|
||||
}
|
||||
|
||||
/// Most probable luma intra modes of the block at (xp, yp) (8.4.2).
|
||||
fn mpm_list(&self, xp: i32, yp: i32) -> [u32; 3] {
|
||||
let cand_a = if self.pic.zavail(xp, yp, xp - 1, yp, self.slice_addr) {
|
||||
let b = self.pic.blk_at(xp - 1, yp);
|
||||
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let cand_b = if self.pic.zavail(xp, yp, xp, yp - 1, self.slice_addr) && yp > ((yp >> self.sps.log2_ctb) << self.sps.log2_ctb) {
|
||||
let b = self.pic.blk_at(xp, yp - 1);
|
||||
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cand_a == cand_b {
|
||||
if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] }
|
||||
} else {
|
||||
let c = if cand_a != 0 && cand_b != 0 {
|
||||
0
|
||||
} else if cand_a != 1 && cand_b != 1 {
|
||||
1
|
||||
} else {
|
||||
26
|
||||
};
|
||||
[cand_a, cand_b, c]
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_intra_modes(&mut self, x0: i32, y0: i32, n: i32, part: PartMode) -> Result<()> {
|
||||
let nb = if part == PartMode::PNxN { 2 } else { 1 };
|
||||
let pb = n / nb;
|
||||
@@ -838,31 +876,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
for i in 0..nb {
|
||||
let k = (j * nb + i) as usize;
|
||||
let (xp, yp) = (x0 + i * pb, y0 + j * pb);
|
||||
// candidate modes (8.4.2)
|
||||
let cand_a = if self.pic.zavail(xp, yp, xp - 1, yp, self.slice_addr) {
|
||||
let b = self.pic.blk_at(xp - 1, yp);
|
||||
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let cand_b = if self.pic.zavail(xp, yp, xp, yp - 1, self.slice_addr) && yp > ((yp >> self.sps.log2_ctb) << self.sps.log2_ctb) {
|
||||
let b = self.pic.blk_at(xp, yp - 1);
|
||||
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let mut list = if cand_a == cand_b {
|
||||
if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] }
|
||||
} else {
|
||||
let c = if cand_a != 0 && cand_b != 0 {
|
||||
0
|
||||
} else if cand_a != 1 && cand_b != 1 {
|
||||
1
|
||||
} else {
|
||||
26
|
||||
};
|
||||
[cand_a, cand_b, c]
|
||||
};
|
||||
let mut list = self.mpm_list(xp, yp);
|
||||
let mode = if prev[k] {
|
||||
let idx = if self.bypass() == 0 {
|
||||
0
|
||||
@@ -938,33 +952,39 @@ impl<'a> SliceDecoder<'a> {
|
||||
self.transform_unit(x0, y0, xb, yb, log2, blk_idx, cbf_luma, cbf_c)
|
||||
}
|
||||
|
||||
/// cu_qp_delta_abs / sign and QP update (7.3.8.14).
|
||||
fn parse_cu_qp_delta(&mut self) -> Result<()> {
|
||||
// cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0
|
||||
let mut v = 0;
|
||||
while v < 5 && self.dd(CU_QP_DELTA_ABS + if v == 0 { 0 } else { 1 }) == 1 {
|
||||
v += 1;
|
||||
}
|
||||
if v == 5 {
|
||||
let mut k = 0;
|
||||
while self.bypass() == 1 {
|
||||
v += 1 << k;
|
||||
k += 1;
|
||||
ensure!(k < 32, "invalid cu_qp_delta_abs");
|
||||
}
|
||||
v += self.bypass_bits(k) as i32;
|
||||
}
|
||||
if v != 0 && self.bypass() == 1 {
|
||||
v = -v;
|
||||
}
|
||||
let off = 6 * (self.pic.bd_y as i32 - 8);
|
||||
ensure!(v >= -(26 + off / 2) && v <= 25 + off / 2, "CuQpDeltaVal out of range");
|
||||
self.is_cu_qp_delta_coded = true;
|
||||
self.cu_qp_delta = v;
|
||||
self.update_qp();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transform_unit(&mut self, x0: i32, y0: i32, xb: i32, yb: i32, log2: u32, blk_idx: u32, cbf_luma: bool, cbf_c: [bool; 2]) -> Result<()> {
|
||||
let n = 1i32 << log2;
|
||||
let chroma_here = log2 > 2 || blk_idx == 3;
|
||||
let cbf_chroma = cbf_c[0] || cbf_c[1];
|
||||
if (cbf_luma || cbf_chroma) && self.pps.cu_qp_delta_enabled && !self.is_cu_qp_delta_coded {
|
||||
// cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0
|
||||
let mut v = 0;
|
||||
while v < 5 && self.dd(CU_QP_DELTA_ABS + if v == 0 { 0 } else { 1 }) == 1 {
|
||||
v += 1;
|
||||
}
|
||||
if v == 5 {
|
||||
let mut k = 0;
|
||||
while self.bypass() == 1 {
|
||||
v += 1 << k;
|
||||
k += 1;
|
||||
ensure!(k < 32, "invalid cu_qp_delta_abs");
|
||||
}
|
||||
v += self.bypass_bits(k) as i32;
|
||||
}
|
||||
if v != 0 && self.bypass() == 1 {
|
||||
v = -v;
|
||||
}
|
||||
let off = 6 * (self.pic.bd_y as i32 - 8);
|
||||
ensure!(v >= -(26 + off / 2) && v <= 25 + off / 2, "CuQpDeltaVal out of range");
|
||||
self.is_cu_qp_delta_coded = true;
|
||||
self.cu_qp_delta = v;
|
||||
self.update_qp();
|
||||
self.parse_cu_qp_delta()?;
|
||||
}
|
||||
// luma
|
||||
if self.cu_intra {
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct Pcm {
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
const MAX_PCM_SAMPLES: usize = 64 * 1024 * 1024;
|
||||
|
||||
impl Pcm {
|
||||
/// Sample frames (samples per channel).
|
||||
pub fn frames(&self) -> usize {
|
||||
@@ -47,8 +49,10 @@ impl Pcm {
|
||||
|
||||
fn push_planar(planes: &[Vec<f32>], out: &mut Vec<f32>) {
|
||||
let n = planes.iter().map(Vec::len).min().unwrap_or(0);
|
||||
out.reserve(n * planes.len());
|
||||
for i in 0..n {
|
||||
let remaining = MAX_PCM_SAMPLES.saturating_sub(out.len());
|
||||
let to_push = n.min(remaining / planes.len().max(1));
|
||||
out.reserve(to_push * planes.len());
|
||||
for i in 0..to_push {
|
||||
for p in planes {
|
||||
out.push(p[i]);
|
||||
}
|
||||
@@ -96,7 +100,13 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
|
||||
let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for i in 0..stream.packets.len() {
|
||||
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
let Ok(p) = file.read_packet(src, s, i) else { continue };
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match dec.decode(Some(&p)) {
|
||||
Ok(planes) => Pcm::push_planar(&planes, &mut out),
|
||||
Err(e) => log::debug!("opus packet {i}: {e}"),
|
||||
@@ -126,7 +136,13 @@ fn decode_mkv_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
|
||||
let mut out = Vec::new();
|
||||
let n = file.tracks.get(ti).map(|t| t.samples.len()).unwrap_or(0);
|
||||
for i in 0..n {
|
||||
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
let Ok(p) = file.read_sample(src, ti, i) else { continue };
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match dec.decode(Some(&p)) {
|
||||
Ok(planes) => Pcm::push_planar(&planes, &mut out),
|
||||
Err(e) => log::debug!("opus packet {i}: {e}"),
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+1
@@ -37,4 +37,5 @@ $FF $VID $TONE44 -c:v libx264 -pix_fmt yuv420p -g 5 -bf 2 -c:a aac -b:a 48k -sho
|
||||
$FF $VID -c:v libx264 -pix_fmt yuv420p -g 5 -bf 0 $META clip-h264.mov
|
||||
$FF $VID -c:v libx265 -pix_fmt yuv420p -x265-params log-level=error:keyint=5 -tag:v hvc1 $META clip-hevc.mp4
|
||||
$FF $VID $TONE -c:v libvpx-vp9 -pix_fmt yuv420p -g 5 -b:v 100k -c:a libopus -b:a 32k -shortest $META clip-vp9.webm
|
||||
$FF $VID $TONE44 -c:v libvpx -pix_fmt yuv420p -g 5 -b:v 100k -deadline realtime -c:a vorbis -strict -2 -ac 2 -b:a 48k -shortest $META clip-vp8.webm
|
||||
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i "$TMP_DIR/deckcraft-fixture-64x64.rgb" -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
|
||||
|
||||
@@ -298,3 +298,27 @@ fn table_text_takes_the_masters_other_style_and_shapes_the_default_text_style()
|
||||
assert_eq!(pt(&Shape { text: Some(body.clone()), ..Default::default() }), Some(26.0), "a shape");
|
||||
assert_eq!(pt(&Shape { text: Some(body), text_box: true, ..Default::default() }), Some(26.0), "a text box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_and_mail_links_are_safe_to_open() {
|
||||
use crate::text::safe_url;
|
||||
for url in ["https://example.com", " HTTP://example.com/x", "mailto:someone@example.com"] {
|
||||
assert!(safe_url(url), "{url}");
|
||||
}
|
||||
let refused = [
|
||||
"https://",
|
||||
"mailto:",
|
||||
"file:///C:/notes.pdf",
|
||||
"notes.pdf",
|
||||
"javascript:alert(1)",
|
||||
"ms-msdt:/id PCWDiagnostic",
|
||||
"ldap://attacker.example/x",
|
||||
"//attacker.example/share",
|
||||
"\\\\attacker.example\\share",
|
||||
"smb://attacker.example/share",
|
||||
"search-ms:query=x",
|
||||
];
|
||||
for url in refused {
|
||||
assert!(!safe_url(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,19 @@ pub enum Action {
|
||||
PlayMedia,
|
||||
}
|
||||
|
||||
/// Can a link from a (possibly untrusted) deck be opened outside it, by the slide show or by
|
||||
/// whoever opens an exported file? Only web pages and mail: `file:`, `javascript:`, relative paths
|
||||
/// and other schemes (OS protocol handlers, network shares) are not.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_show_returns() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -241,26 +241,28 @@ fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> Option<(f64, f64)>
|
||||
sxx += x * x;
|
||||
sxy += x * (y - b);
|
||||
}
|
||||
if sxx.abs() < 1e-18 {
|
||||
if sxx <= 0.0 || !sxx.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let m = sxy / sxx;
|
||||
return (m.is_finite() && b.is_finite()).then_some((b, m));
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0);
|
||||
let mx = xs.iter().sum::<f64>() / n;
|
||||
let my = ys.iter().sum::<f64>() / n;
|
||||
let (mut sxx, mut sxy) = (0.0, 0.0);
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
sx += x;
|
||||
sy += y;
|
||||
sxx += x * x;
|
||||
sxy += x * y;
|
||||
let dx = x - mx;
|
||||
sxx += dx * dx;
|
||||
sxy += dx * (y - my);
|
||||
}
|
||||
let den = n * sxx - sx * sx;
|
||||
if den.abs() < 1e-18 {
|
||||
// Constant x with a large offset leaves rounding residue in `mx`, so compare against the
|
||||
// rounding scale of the mean instead of zero.
|
||||
if sxx <= 4.0 * n * (f64::EPSILON * mx.abs()).powi(2) || !sxx.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let m = (n * sxy - sx * sy) / den;
|
||||
let b = (sy - m * sx) / n;
|
||||
let m = sxy / sxx;
|
||||
let b = my - m * mx;
|
||||
(m.is_finite() && b.is_finite()).then_some((b, m))
|
||||
}
|
||||
|
||||
@@ -561,6 +563,51 @@ mod tests {
|
||||
Trendline { kind, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fits_tiny_x_values() {
|
||||
let xs = [1e-10, 2e-10, 3e-10, 4e-10, 5e-10];
|
||||
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of tiny x values");
|
||||
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fits_offset_but_narrow_x_range() {
|
||||
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of offset x values");
|
||||
assert!((fit.y(45000.02).unwrap() - 3.0).abs() < 1e-4);
|
||||
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
|
||||
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of 1e7 offset");
|
||||
assert!((fit.y(1e7 + 2.0).unwrap() - 3.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fixed_intercept_on_offset_narrow_x_range() {
|
||||
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
|
||||
let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x).collect();
|
||||
let mut forced = line(TrendlineType::Linear);
|
||||
forced.intercept = Some(0.0);
|
||||
let fit = forced.fit(&xs, &ys).expect("fixed-intercept fit");
|
||||
assert!((fit.y(1e7 + 2.0).unwrap() / (2.0 * (1e7 + 2.0)) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fixed_intercept_rejects_overflowing_x() {
|
||||
let xs = [1e160, 2e160, 3e160];
|
||||
let ys = [1.0, 2.0, 3.0];
|
||||
let mut forced = line(TrendlineType::Linear);
|
||||
forced.intercept = Some(0.0);
|
||||
assert!(forced.fit(&xs, &ys).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_rejects_constant_x_with_large_offset() {
|
||||
let xs = [45000.1; 5];
|
||||
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert!(line(TrendlineType::Linear).fit(&xs, &ys).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_recovers_slope_and_a_fixed_intercept() {
|
||||
let xs = [1.0, 2.0, 3.0, 4.0];
|
||||
|
||||
+41
-14
@@ -212,10 +212,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
let mut pos = 0u64;
|
||||
let mut found_any = false;
|
||||
let mut hdr = [0u8; 27 + 255];
|
||||
let mut scanner = CaptureScanner::new();
|
||||
while pos + 27 <= len {
|
||||
src.read_at(pos, &mut hdr[..27])?;
|
||||
if &hdr[..4] != b"OggS" || hdr[4] != 0 {
|
||||
match find_capture(src, pos + 1, len)? {
|
||||
match scanner.find(src, pos + 1, len)? {
|
||||
Some(p) => {
|
||||
if found_any {
|
||||
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
|
||||
@@ -336,21 +337,47 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
Ok(OggFile { streams, warnings })
|
||||
}
|
||||
|
||||
fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
|
||||
let mut at = from;
|
||||
while at + 4 <= len {
|
||||
let n = ((len - at) as usize).min(1 << 20);
|
||||
let mut buf = vec![0u8; n];
|
||||
src.read_at(at, &mut buf)?;
|
||||
if let Some(i) = buf.windows(4).position(|w| w == b"OggS") {
|
||||
return Ok(Some(at + i as u64));
|
||||
struct CaptureScanner {
|
||||
buf: Vec<u8>,
|
||||
buf_start: u64,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl CaptureScanner {
|
||||
fn new() -> Self {
|
||||
Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 }
|
||||
}
|
||||
|
||||
fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
|
||||
let mut at = from;
|
||||
while at + 4 <= len {
|
||||
let buf_end = self.buf_start + self.buf.len() as u64;
|
||||
if at >= self.buf_start && at + 4 <= buf_end {
|
||||
let off = (at - self.buf_start) as usize;
|
||||
if let Some(i) = self.buf[off..].windows(4).position(|w| w == b"OggS") {
|
||||
return Ok(Some(at + i as u64));
|
||||
}
|
||||
at = buf_end.saturating_sub(3);
|
||||
continue;
|
||||
}
|
||||
if self.budget == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let n = ((len - at) as usize).min(64 * 1024).min(self.budget);
|
||||
if n < 4 {
|
||||
break;
|
||||
}
|
||||
self.buf.resize(n, 0);
|
||||
src.read_at(at, &mut self.buf)?;
|
||||
self.buf_start = at;
|
||||
self.budget = self.budget.saturating_sub(n);
|
||||
if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
|
||||
return Ok(Some(at + i as u64));
|
||||
}
|
||||
at += (n - 3) as u64;
|
||||
}
|
||||
if n < 4 {
|
||||
break;
|
||||
}
|
||||
at += (n - 3) as u64;
|
||||
Ok(None)
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
|
||||
|
||||
@@ -17,7 +17,7 @@ use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action as ClickAction;
|
||||
use deckcraft_model::text::{Action as ClickAction, safe_url};
|
||||
use deckcraft_render::{PlacedText, RenderOpts};
|
||||
use krilla::action::{Action, LinkAction};
|
||||
use krilla::annotation::{Annotation, LinkAnnotation, Target};
|
||||
@@ -565,7 +565,9 @@ impl Exporter<'_> {
|
||||
slide.and_then(|s| page_of.get(&s)).map(|p| Target::Destination(XyzDestination::new(*p, Point::from_xy(0.0, 0.0)).into()))
|
||||
};
|
||||
let target = match &l.link.action {
|
||||
ClickAction::Url { url } => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))),
|
||||
// Only the links the slide show opens (web and mail): a reader of the file gets the rest
|
||||
// as plain text.
|
||||
ClickAction::Url { url } if safe_url(url) => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))),
|
||||
ClickAction::Slide { slide } => goto(pres.slides.iter().position(|x| x.id == *slide)),
|
||||
ClickAction::NextSlide => goto(Some(i + 1)),
|
||||
ClickAction::PreviousSlide => goto(i.checked_sub(1)),
|
||||
|
||||
@@ -139,6 +139,65 @@ fn text_layer_links_and_bookmarks_survive_serialization() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Links from an untrusted deck: only web and mail links become PDF links. Scripts, `data:`,
|
||||
/// local and relative files, OS protocol handlers and network shares (a UNC path leaks the user's
|
||||
/// NTLM credentials on Windows) do not.
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn unsafe_url_links_are_not_exported() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::object::{Array, Dict, String as PdfString};
|
||||
let safe = ["https://example.com/deck", "mailto:someone@example.com"];
|
||||
let refused = [
|
||||
"file:///C:/talks/notes.pdf",
|
||||
"notes.pdf",
|
||||
"ms-msdt:/id PCWDiagnostic /skip force",
|
||||
"ms-word:ofe|u|\\\\attacker.example\\share\\x.docx",
|
||||
"ldap://attacker.example/x",
|
||||
"file:///%5C%5Cattacker.example/share/x",
|
||||
"javascript:alert(1)",
|
||||
"\t JavaScript:alert(1)",
|
||||
"java\nscript:alert(1)",
|
||||
"vbscript:MsgBox(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"//attacker.example/share/x",
|
||||
"file://attacker.example/share/x",
|
||||
"file:////attacker.example/share/x",
|
||||
"smb://attacker.example/share/x",
|
||||
"search-ms:query=x&crumb=location:\\\\attacker.example\\share",
|
||||
"search:query=x&crumb=location:\\\\attacker.example\\share",
|
||||
];
|
||||
let mut p = deck(1);
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
slide.shapes = (0..)
|
||||
.zip(safe.iter().chain(&refused))
|
||||
.map(|(i, url)| Shape {
|
||||
id: ShapeId(9000 + i),
|
||||
xfrm: Some(Xfrm::new(20.0, 20.0 + 32.0 * f64::from(i), 400.0, 26.0)),
|
||||
click: Some(Hyperlink {
|
||||
action: Action::Url { url: url.to_string() },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let annotations = pdf.pages()[0].raw().get::<Array<'_>>(b"Annots").unwrap();
|
||||
let uris: Vec<String> = annotations
|
||||
.iter::<Dict<'_>>()
|
||||
.filter_map(|a| a.get::<Dict<'_>>(b"A")?.get::<PdfString<'_>>(b"URI"))
|
||||
.map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
|
||||
.collect();
|
||||
assert_eq!(uris, safe);
|
||||
}
|
||||
|
||||
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
|
||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||
|
||||
+18
-2
@@ -279,7 +279,11 @@ fn percent_decode(s: &str) -> String {
|
||||
while i < b.len() {
|
||||
let c = b.get(i).copied().unwrap_or(0);
|
||||
if c == b'%'
|
||||
&& let Some(h) = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
&& let Some(h) = b
|
||||
.get(i + 1..i + 3)
|
||||
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
|
||||
.and_then(|h| std::str::from_utf8(h).ok())
|
||||
.and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(h);
|
||||
i += 3;
|
||||
@@ -293,7 +297,7 @@ fn percent_decode(s: &str) -> String {
|
||||
|
||||
/// Resolve a relative target against a base directory into a part name.
|
||||
pub fn resolve(base: &str, target: &str) -> String {
|
||||
let target = percent_decode(target.split('#').next().unwrap_or(""));
|
||||
let target = percent_decode(target.split('#').next().unwrap_or("")).replace('\\', "/");
|
||||
let target = target.as_str();
|
||||
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
|
||||
for s in target.split('/') {
|
||||
@@ -464,6 +468,12 @@ mod tests {
|
||||
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_percent_decode_requires_two_hex_digits() {
|
||||
assert_eq!(resolve("ppt/slides", "../media/a%+5b.png"), "ppt/media/a%+5b.png");
|
||||
assert_eq!(resolve("ppt/slides", "../media/a%20b.png"), "ppt/media/a b.png");
|
||||
}
|
||||
|
||||
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
@@ -497,6 +507,12 @@ mod tests {
|
||||
assert!(p.xml_bytes("missing.xml").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_backslash_target() {
|
||||
assert_eq!(resolve("ppt/slides", "..\\media\\image1.png"), "ppt/media/image1.png");
|
||||
assert_eq!(resolve("ppt/slides", "\\ppt\\media\\a.png"), "ppt/media/a.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_resolution() {
|
||||
assert_eq!(resolve("ppt/slides", "../media/image1.png"), "ppt/media/image1.png");
|
||||
|
||||
@@ -447,6 +447,17 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
|
||||
}
|
||||
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
|
||||
// An embedded object's fallback picture (`p:oleObj/p:pic`) can carry a fill and an outline,
|
||||
// which PowerPoint draws behind and around the preview (old equation objects on coloured
|
||||
// boxes). The frame's own position stays.
|
||||
if let Some(pr) = gd.and_then(|g| g.find("oleObj")).and_then(|o| o.find("pic")).and_then(|p| p.child("spPr")) {
|
||||
if s.fill.is_none() {
|
||||
s.fill = dml::fill_of(imp, part, pr);
|
||||
}
|
||||
if s.line.is_none() {
|
||||
s.line = pr.child("ln").map(|ln| dml::line(imp, part, ln));
|
||||
}
|
||||
}
|
||||
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
|
||||
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
||||
} else if uri.ends_with("/diagram") {
|
||||
|
||||
@@ -909,8 +909,12 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
|
||||
w.empty0("p:nvPr");
|
||||
w.close("p:nvPicPr");
|
||||
dml::blip_fill(w, x, o, "p:blipFill", fill);
|
||||
// As for any picture, a colour or pattern fill shows behind the image (an object's preview
|
||||
// keeps its fallback picture's fill).
|
||||
let mut s2 = s.clone();
|
||||
s2.fill = None;
|
||||
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
|
||||
s2.fill = None;
|
||||
}
|
||||
sp_pr(w, x, o, &s2, true);
|
||||
w.close("p:pic");
|
||||
} else {
|
||||
|
||||
@@ -96,3 +96,54 @@ fn an_old_ole_object_shows_its_vml_preview() {
|
||||
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
|
||||
}
|
||||
|
||||
/// PowerPoint 2010 and later: `p:oleObj` with a fallback `p:pic` inside `mc:AlternateContent`, whose
|
||||
/// `spPr` can give the object a fill and an outline (old equation objects on coloured boxes).
|
||||
#[test]
|
||||
fn an_ole_objects_fallback_picture_gives_it_its_fill_and_outline() {
|
||||
let pic = |spr: &str| {
|
||||
format!(
|
||||
r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/></p:oleObj></mc:Choice><mc:Fallback><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/><p:pic><p:nvPicPr><p:cNvPr id="0" name="Object 3"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImg"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>{spr}</p:spPr></p:pic></p:oleObj></mc:Fallback></mc:AlternateContent></a:graphicData></a:graphic></p:graphicFrame>"#
|
||||
)
|
||||
};
|
||||
let deck = |frame: String| {
|
||||
with_parts(&[("ppt/media/eq.png", png()), ("ppt/embeddings/oleObject1.bin", vec![0; 16])], move |name, s| {
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
s.replacen(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/eq.png"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
};
|
||||
let object = |bytes: Vec<u8>| {
|
||||
let p = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object").clone();
|
||||
assert!(matches!(&s.kind, ShapeKind::Opaque { preview: Some(_), label, .. } if label == "Equation.3"), "{:?}", s.kind);
|
||||
s
|
||||
};
|
||||
let filled = object(deck(pic(
|
||||
r#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill><a:ln w="12700"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln>"#,
|
||||
)));
|
||||
let orange = deckcraft_model::style::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0xFF, 0x99, 0)));
|
||||
assert_eq!(filled.fill, Some(orange.clone()));
|
||||
assert_eq!(filled.line.as_ref().and_then(|l| l.width), Some(1.0));
|
||||
// Saved as its preview picture (its kept XML can't be written back), it keeps the fill.
|
||||
let mut p = deckcraft_pptx::import(&deck(pic(r#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill>"#))).expect("import");
|
||||
for s in &mut std::sync::Arc::make_mut(&mut p.slides[0]).shapes {
|
||||
if let ShapeKind::Opaque { xml, .. } = &mut s.kind {
|
||||
xml.clear();
|
||||
}
|
||||
}
|
||||
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
|
||||
let saved = back.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
|
||||
assert!(matches!(saved.kind, ShapeKind::Picture { .. }), "{:?}", saved.kind);
|
||||
assert_eq!(saved.fill, Some(orange));
|
||||
// Without a fill of its own the object stays unfilled.
|
||||
assert_eq!(object(deck(pic(""))).fill, None);
|
||||
}
|
||||
|
||||
@@ -170,19 +170,37 @@ pub fn squarify(vals: &[f64], r: Rect) -> Vec<Rect> {
|
||||
let mut i = 0;
|
||||
while i < order.len() {
|
||||
let short = free.width().min(free.height());
|
||||
let worst = |row: &[usize]| {
|
||||
let areas: Vec<f64> = row.iter().map(|&k| vals[k] * scale).collect();
|
||||
let sum: f64 = areas.iter().sum();
|
||||
let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min));
|
||||
((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn))
|
||||
};
|
||||
if !short.is_finite() || short <= 1e-9 {
|
||||
break;
|
||||
}
|
||||
let s2 = short * short;
|
||||
let a0 = vals[order[i]] * scale;
|
||||
let mut cur_sum = a0;
|
||||
let mut cur_max = a0;
|
||||
let mut cur_min = a0;
|
||||
let mut cur_worst = ((s2 * cur_max) / (cur_sum * cur_sum)).max((cur_sum * cur_sum) / (s2 * cur_min));
|
||||
let mut end = i + 1;
|
||||
while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) {
|
||||
end += 1;
|
||||
while end < order.len() {
|
||||
let next_a = vals[order[end]] * scale;
|
||||
let cand_sum = cur_sum + next_a;
|
||||
let cand_max = cur_max.max(next_a);
|
||||
let cand_min = cur_min.min(next_a);
|
||||
let cand_worst = ((s2 * cand_max) / (cand_sum * cand_sum)).max((cand_sum * cand_sum) / (s2 * cand_min));
|
||||
if cand_worst.is_finite() && cand_worst <= cur_worst {
|
||||
cur_sum = cand_sum;
|
||||
cur_max = cand_max;
|
||||
cur_min = cand_min;
|
||||
cur_worst = cand_worst;
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let row = &order[i..end];
|
||||
let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum();
|
||||
let thick = sum / short;
|
||||
let thick = cur_sum / short;
|
||||
if !thick.is_finite() || thick <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let mut at = 0.0;
|
||||
for &k in row {
|
||||
let len = vals[k] * scale / thick;
|
||||
@@ -570,4 +588,13 @@ mod tests {
|
||||
assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]);
|
||||
assert_eq!(c.value_range(), (0.0, 140.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squarify_handles_skewed_and_exhausted_rect() {
|
||||
let vals = vec![1e12, 1e-6, 1e-6, 1e-6];
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let tiles = squarify(&vals, r);
|
||||
assert_eq!(tiles.len(), vals.len());
|
||||
assert!(tiles[0].area() > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ const GIF_SIDE: u32 = 1024;
|
||||
const GIF_BUDGET: usize = 64 << 20;
|
||||
const GIF_MAX_FRAMES: usize = 1000;
|
||||
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
|
||||
const GIF_MAX_TOTAL_CANVAS_PIXELS: u64 = 256 * 1024 * 1024;
|
||||
const GIF_CACHE_BUDGET: usize = 192 << 20;
|
||||
|
||||
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
|
||||
@@ -394,13 +395,22 @@ fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
|
||||
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
|
||||
let size = nw as usize * nh as usize * 4;
|
||||
let canvas_pixels = (w as u64) * (h as u64);
|
||||
let mut total_canvas_pixels: u64 = 0;
|
||||
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
|
||||
for f in dec.into_frames() {
|
||||
let Ok(f) = f else { break };
|
||||
let mut iter = dec.into_frames();
|
||||
loop {
|
||||
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
|
||||
log::info!("animated GIF: keeping its first {} frames", frames.len());
|
||||
break;
|
||||
}
|
||||
if total_canvas_pixels.saturating_add(canvas_pixels) > GIF_MAX_TOTAL_CANVAS_PIXELS {
|
||||
log::info!("animated GIF: canvas pixel limit reached");
|
||||
break;
|
||||
}
|
||||
let Some(f) = iter.next() else { break };
|
||||
let Ok(f) = f else { break };
|
||||
total_canvas_pixels = total_canvas_pixels.saturating_add(canvas_pixels);
|
||||
let (n, d) = f.delay().numer_denom_ms();
|
||||
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
|
||||
// Like browsers: frames of 10 ms or less show for 100 ms.
|
||||
|
||||
+116
-33
@@ -321,6 +321,13 @@ pub struct Renderer {
|
||||
group_fill: Option<(Fill, Affine, Rect)>,
|
||||
/// Their aligned outlines, kept from frame to frame.
|
||||
morphs: morph_path::Cache,
|
||||
/// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group.
|
||||
in_shadow_pass: bool,
|
||||
/// Cumulative shape traversal counter to prevent nested group amplification DoS.
|
||||
render_work: usize,
|
||||
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
|
||||
/// of the normal drawing that follows.
|
||||
shadow_work: usize,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
@@ -333,6 +340,9 @@ impl Default for Renderer {
|
||||
view: Affine::IDENTITY,
|
||||
group_fill: None,
|
||||
morphs: morph_path::Cache::default(),
|
||||
in_shadow_pass: false,
|
||||
render_work: 0,
|
||||
shadow_work: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,6 +688,9 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.in_shadow_pass = false;
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||||
@@ -690,6 +703,9 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.in_shadow_pass = false;
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||||
@@ -721,6 +737,9 @@ impl Renderer {
|
||||
opts: &RenderOpts,
|
||||
) -> Image {
|
||||
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.in_shadow_pass = false;
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
|
||||
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
|
||||
// Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
|
||||
@@ -768,6 +787,9 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.in_shadow_pass = false;
|
||||
let (w, h) = output_size(pres, opts);
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||||
@@ -896,6 +918,11 @@ impl Renderer {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
return;
|
||||
}
|
||||
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
|
||||
*work = work.saturating_add(1);
|
||||
if *work > 100_000 {
|
||||
return;
|
||||
}
|
||||
// Footer placeholders on layouts/masters show only on slides that include them.
|
||||
let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default();
|
||||
if !st.visible || st.opacity <= 0.001 {
|
||||
@@ -936,7 +963,9 @@ impl Renderer {
|
||||
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
|
||||
// children drawn once more under the shadow's offset, scale and skew, recoloured to
|
||||
// the shadow colour and blurred, below the group.
|
||||
if let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) {
|
||||
if !self.in_shadow_pass
|
||||
&& let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref())
|
||||
{
|
||||
let c = rctx.color(&sh.color, None);
|
||||
let k = scale_of(m);
|
||||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||||
@@ -948,9 +977,11 @@ impl Renderer {
|
||||
let shift = pre * m * own * m.inverse();
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
self.in_shadow_pass = true;
|
||||
for ch in children {
|
||||
self.shape(ctx, f, rctx, ch, shift * gm, depth + 1);
|
||||
}
|
||||
self.in_shadow_pass = false;
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
@@ -1025,6 +1056,51 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per
|
||||
/// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill`
|
||||
/// the group's fill.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fill_geometry(
|
||||
&self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
rctx: &Ctx,
|
||||
fl: &Fill,
|
||||
fill_ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
(w, h): (f64, f64),
|
||||
m: Affine,
|
||||
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
|
||||
) {
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
match (fl, &self.bg) {
|
||||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||||
// shape), shows through the shape.
|
||||
(Fill::Background, Some((bg, ph, view))) => {
|
||||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
let path = (view.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
// `grpFill`: the group's fill, laid out over the whole group.
|
||||
(Fill::Group, _) => {
|
||||
if let Some((gf, gm, gbox)) = &self.group_fill
|
||||
&& gm.determinant().abs() > 1e-12
|
||||
{
|
||||
let path = (gm.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn geometry_shape(
|
||||
&mut self,
|
||||
@@ -1121,6 +1197,13 @@ impl Renderer {
|
||||
// Fill.
|
||||
match &s.kind {
|
||||
ShapeKind::Picture { fill: pf } => {
|
||||
// The picture's own fill shows behind its transparent pixels, as in PowerPoint (an
|
||||
// emphasis tint recolours it, as it does any shape's fill).
|
||||
if s.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
&& let Some(fl) = &fill
|
||||
{
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
ctx.push_clip_layer(&outline);
|
||||
@@ -1131,6 +1214,13 @@ impl Renderer {
|
||||
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||||
// preview picture, stretched over the frame, as PowerPoint draws it.
|
||||
ShapeKind::Opaque { preview: Some(media), .. } => {
|
||||
// An embedded object's fallback picture can carry a fill (old equation objects
|
||||
// drawn on coloured boxes): behind the preview, as for a picture.
|
||||
if s.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
&& let Some(fl) = &fill
|
||||
{
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||||
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
|
||||
@@ -1155,33 +1245,7 @@ impl Renderer {
|
||||
if let Some(fl) = &fill
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
|
||||
{
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
match (fl, &self.bg) {
|
||||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||||
// shape), shows through the shape.
|
||||
(Fill::Background, Some((bg, ph, view))) => {
|
||||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
let path = (view.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
// `grpFill`: the group's fill, laid out over the whole group.
|
||||
(Fill::Group, _) => {
|
||||
if let Some((gf, gm, gbox)) = &self.group_fill
|
||||
&& gm.determinant().abs() > 1e-12
|
||||
{
|
||||
let path = (gm.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1925,16 +1989,21 @@ fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath {
|
||||
&& start > 0.0
|
||||
{
|
||||
let mut left = start;
|
||||
while let Some(seg) = segs.first().copied() {
|
||||
let mut drop_count = 0;
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
let l = seg.arclen(ACC);
|
||||
if l > left {
|
||||
if let Some(s0) = segs.first_mut() {
|
||||
*s0 = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
|
||||
}
|
||||
let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
|
||||
segs.drain(..i);
|
||||
segs[0] = partial;
|
||||
drop_count = 0;
|
||||
break;
|
||||
}
|
||||
left -= l;
|
||||
segs.remove(0);
|
||||
drop_count = i + 1;
|
||||
}
|
||||
if drop_count > 0 {
|
||||
segs.drain(..drop_count);
|
||||
}
|
||||
}
|
||||
if let Some((segs, false)) = subs.last_mut()
|
||||
@@ -2243,6 +2312,20 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
if let Some(&(first, count)) = l.para_lines.get(para) {
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
let end = (first + count).min(l.lines.len());
|
||||
if first < end {
|
||||
return l.lines[first..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
|
||||
.map(|(i, _)| first + i)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
l.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
||||
+27
-15
@@ -138,8 +138,17 @@ fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_style<'a>(ctx: &'a Ctx, t: &Table) -> Option<&'a TableStyleDef> {
|
||||
ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
|
||||
look_with_def(ctx, resolve_style(ctx, t), t, r, c)
|
||||
}
|
||||
|
||||
pub fn look_with_def(ctx: &Ctx, def: Option<&TableStyleDef>, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
if let Some(def) = def {
|
||||
return look_def(ctx, def, t, r, c);
|
||||
}
|
||||
let a = accent(ctx, &t.style);
|
||||
@@ -242,7 +251,11 @@ fn edges(sizes: &[f64]) -> Vec<f64> {
|
||||
|
||||
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
|
||||
pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
||||
let lk = look(rctx, t, r, c);
|
||||
cell_body_with_def(rctx, resolve_style(rctx, t), t, r, c, cell)
|
||||
}
|
||||
|
||||
pub fn cell_body_with_def(rctx: &Ctx, def: Option<&TableStyleDef>, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
||||
let lk = look_with_def(rctx, def, t, r, c);
|
||||
let mut body = cell.text.clone();
|
||||
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
||||
body.body.inset_l = Some(ml);
|
||||
@@ -272,6 +285,11 @@ pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> Text
|
||||
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
|
||||
/// Cells spanning several rows and vertical text keep the stored height.
|
||||
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
|
||||
row_heights_with_def(rctx, resolve_style(rctx, t), t, fields)
|
||||
}
|
||||
|
||||
/// [`row_heights`] with the table's style already resolved, for a caller that needs it too.
|
||||
fn row_heights_with_def(rctx: &Ctx, style_def: Option<&TableStyleDef>, t: &Table, fields: &dyn Fields) -> Vec<f64> {
|
||||
let xs = edges(&t.cols);
|
||||
let mut out = Vec::with_capacity(t.rows.len());
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
@@ -282,7 +300,7 @@ pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
|
||||
}
|
||||
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
|
||||
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
|
||||
let body = cell_body(rctx, t, ri, ci, cell);
|
||||
let body = cell_body_with_def(rctx, style_def, t, ri, ci, cell);
|
||||
let tmp = Shape::table_cell(body.clone());
|
||||
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
|
||||
if need.is_finite() {
|
||||
@@ -347,16 +365,10 @@ pub fn draw(
|
||||
editing: bool,
|
||||
) -> Option<kurbo::Rect> {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
let style_def = resolve_style(rctx, t);
|
||||
let rects = cell_rects(t, &row_heights_with_def(rctx, style_def, t, fields));
|
||||
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
|
||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||
let style_bg = rctx
|
||||
.pres
|
||||
.table_styles
|
||||
.iter()
|
||||
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
||||
.and_then(|d| d.background.as_ref())
|
||||
.and_then(|b| style_fill(rctx, b));
|
||||
let style_bg = style_def.and_then(|d| d.background.as_ref()).and_then(|b| style_fill(rctx, b));
|
||||
let own_bg = t.background.clone().map(|f| (f, None));
|
||||
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
|
||||
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
|
||||
@@ -366,7 +378,7 @@ pub fn draw(
|
||||
}
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
let lk = look_with_def(rctx, style_def, t, *r, *c);
|
||||
ctx.set_transform(m);
|
||||
match &cell.fill {
|
||||
Some(f) => {
|
||||
@@ -399,7 +411,7 @@ pub fn draw(
|
||||
// Borders: explicit cell borders, else the style's grid.
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
let lk = look_with_def(rctx, style_def, t, *r, *c);
|
||||
let edges = [
|
||||
(rect.x0, rect.y0, rect.x0, rect.y1),
|
||||
(rect.x1, rect.y0, rect.x1, rect.y1),
|
||||
@@ -439,7 +451,7 @@ pub fn draw(
|
||||
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
|
||||
continue;
|
||||
}
|
||||
let body = cell_body(rctx, t, *r, *c, cell);
|
||||
let body = cell_body_with_def(rctx, style_def, t, *r, *c, cell);
|
||||
let tmp = Shape::table_cell(body.clone());
|
||||
#[cfg(test)]
|
||||
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, SlideFields, TextMorph, crop_dest,
|
||||
dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide, render_blend,
|
||||
render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
|
||||
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
|
||||
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
};
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
@@ -2013,6 +2013,42 @@ fn group_child_scale_does_not_scale_line_widths() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_and_an_embedded_objects_preview_draw_their_fill_behind_transparent_pixels() {
|
||||
// PowerPoint draws a picture's own fill behind its transparent pixels (and an old equation
|
||||
// object's fallback picture on its coloured box). Here the left half of the image is clear.
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 20, 160, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let orange = Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 153, 0)));
|
||||
for (object, fill) in [(false, None), (false, Some(orange.clone())), (true, Some(orange.clone()))] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("half.png", "image/png", png.clone()).expect("media");
|
||||
let kind = if object {
|
||||
ShapeKind::Opaque { xml: String::new(), preview: Some(media), label: "Equation.3".into(), parts: vec![] }
|
||||
} else {
|
||||
ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }
|
||||
};
|
||||
let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind, fill: fill.clone(), ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let (clear, opaque) = (img.pixel(200, 200), img.pixel(400, 200));
|
||||
assert!(opaque[2] > 140 && opaque[0] < 40, "the picture's own pixels ({object}): {opaque:?}");
|
||||
if fill.is_some() {
|
||||
assert_eq!(clear[..3], [255, 153, 0], "the fill behind the clear half ({object})");
|
||||
// A fill-colour emphasis recolours that fill, as it does any shape's.
|
||||
let tint = |_: ShapeId| ShapeState { tint: Some(deckcraft_color::Rgba::rgb(0, 160, 60)), ..Default::default() };
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&tint), ..Default::default() });
|
||||
assert_eq!(img.pixel(200, 200)[..3], [0, 160, 60], "the tinted fill behind the clear half ({object})");
|
||||
} else {
|
||||
assert!(clear[..3].iter().all(|v| *v > 240), "no fill: the slide shows through: {clear:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_casts_its_shadow_from_its_pixels() {
|
||||
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
|
||||
|
||||
@@ -164,6 +164,15 @@ fn trimming_a_path_leaves_its_ends_off_by_arc_length() {
|
||||
assert!(trim_ends(&p, 60.0, 50.0).elements().is_empty());
|
||||
let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
|
||||
assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len());
|
||||
|
||||
// Zero-length leading segments trimmed correctly without quadratic copy.
|
||||
let mut p0 = BezPath::new();
|
||||
p0.move_to((0.0, 0.0));
|
||||
p0.line_to((0.0, 0.0));
|
||||
p0.line_to((0.0, 0.0));
|
||||
p0.line_to((50.0, 0.0));
|
||||
let t0 = trim_ends(&p0, 10.0, 0.0);
|
||||
assert_eq!(t0.elements().first(), Some(&PathEl::MoveTo(Point::new(10.0, 0.0))));
|
||||
}
|
||||
|
||||
fn radial(focus: [f64; 4]) -> Shape {
|
||||
|
||||
@@ -16,6 +16,23 @@ const MAX_POINTS: usize = 40_000;
|
||||
|
||||
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
|
||||
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
|
||||
if let Some(&(first, count)) = l.para_lines.get(para) {
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
let end = (first + count).min(l.lines.len());
|
||||
if first < end {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (rel_i, li) in l.lines[first..end].iter().enumerate() {
|
||||
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
|
||||
let d = if inside { 0.0 } else { (li.baseline - y).abs() };
|
||||
if best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((first + rel_i, d));
|
||||
}
|
||||
}
|
||||
return best.map(|(i, _)| i);
|
||||
}
|
||||
}
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) {
|
||||
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
|
||||
|
||||
@@ -24,6 +24,9 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
skrifa = { workspace = true }
|
||||
# kittest's own dev-dependencies do not propagate; tests need both font features explicitly.
|
||||
|
||||
@@ -363,7 +363,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
draw_guides(app, &painter, xf, slide_rect, &t);
|
||||
}
|
||||
if app.ui.ruler {
|
||||
ruler(&painter, avail, slide_rect, scale, &t);
|
||||
ruler(&painter, avail, slide_rect, scale, app.session.prefs.units, &t);
|
||||
ruler_markers(app, ui, &painter, avail, xf, &t);
|
||||
}
|
||||
pointer(app, ui, &resp, xf);
|
||||
@@ -579,44 +579,52 @@ fn master_tex(
|
||||
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
/// The ruler's numbered step in points and the ticks per step: quarter inches, or half
|
||||
/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a
|
||||
/// tick per point or pixel would be a solid bar.
|
||||
pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) {
|
||||
match unit {
|
||||
deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2),
|
||||
_ => (72.0, 4),
|
||||
}
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, t: &Tokens) {
|
||||
let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0));
|
||||
let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y));
|
||||
p.rect_filled(top, CornerRadius::ZERO, t.card);
|
||||
p.rect_filled(left, CornerRadius::ZERO, t.card);
|
||||
let inch = 72.0 * scale;
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let x = slide.min.x + k as f32 * inch / 4.0;
|
||||
if x > slide.max.x {
|
||||
break;
|
||||
}
|
||||
let h = if k % 4 == 0 {
|
||||
let (step, sub) = ruler_steps(unit);
|
||||
let tick = step as f32 * scale / sub as f32;
|
||||
let len = |k: u32| {
|
||||
if k.is_multiple_of(sub) {
|
||||
8.0
|
||||
} else if k % 2 == 0 {
|
||||
} else if k.is_multiple_of(2) {
|
||||
5.0
|
||||
} else {
|
||||
3.0
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let x = slide.min.x + k as f32 * tick;
|
||||
if x > slide.max.x {
|
||||
break;
|
||||
}
|
||||
let h = len(k);
|
||||
p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint));
|
||||
if k % 4 == 0 {
|
||||
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim);
|
||||
if k.is_multiple_of(sub) {
|
||||
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let y = slide.min.y + k as f32 * inch / 4.0;
|
||||
let y = slide.min.y + k as f32 * tick;
|
||||
if y > slide.max.y {
|
||||
break;
|
||||
}
|
||||
let w = if k % 4 == 0 {
|
||||
8.0
|
||||
} else if k % 2 == 0 {
|
||||
5.0
|
||||
} else {
|
||||
3.0
|
||||
};
|
||||
let w = len(k);
|
||||
p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint));
|
||||
k += 1;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,34 @@ fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) ->
|
||||
v
|
||||
}
|
||||
|
||||
/// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside.
|
||||
fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) {
|
||||
d.fields.insert(key.into(), format!("{:.2}", pt / ppu));
|
||||
d.fields.insert(format!("{key}Pt"), pt.to_string());
|
||||
}
|
||||
|
||||
/// A Slide Size field in points: the exact value while the text is still its rendering, else
|
||||
/// the number typed (`default` when it is not one).
|
||||
fn slide_dim_points(d: &Dialog, key: &str, ppu: f64, default: f64) -> f64 {
|
||||
let text = field_of(d, key);
|
||||
if let Some(pt) = exact_of(d, key)
|
||||
&& text == format!("{:.2}", pt / ppu)
|
||||
{
|
||||
return pt;
|
||||
}
|
||||
text.trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).map_or(default, |v| v * ppu)
|
||||
}
|
||||
|
||||
/// The unit's abbreviation in a field label, "Width (cm):".
|
||||
fn unit_abbr(unit: deckcraft_geom::Unit) -> &'static str {
|
||||
match unit {
|
||||
deckcraft_geom::Unit::Inches => "in",
|
||||
deckcraft_geom::Unit::Centimeters => "cm",
|
||||
deckcraft_geom::Unit::Points => "pt",
|
||||
deckcraft_geom::Unit::Pixels => "px",
|
||||
}
|
||||
}
|
||||
|
||||
fn num(d: &mut Dialog, key: &str, default: f64) -> f64 {
|
||||
d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(default)
|
||||
}
|
||||
@@ -1073,35 +1101,46 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
"slideSize" => {
|
||||
let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
|
||||
let len = app.session.prefs.units;
|
||||
let ppu = len.points_per_unit();
|
||||
// Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so
|
||||
// a preset, Portrait or an untouched field is not nudged off by the rounding (13.33",
|
||||
// 33.87 cm read back would make the slide 959.76 or 960.09 pt wide).
|
||||
if !d.fields.contains_key("wPt") {
|
||||
put_slide_dim(d, "w", cur.width, ppu);
|
||||
put_slide_dim(d, "h", cur.height, ppu);
|
||||
}
|
||||
let label = ui.label("Slides sized for:");
|
||||
let preset = d.get("preset", "");
|
||||
let combo = egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| {
|
||||
for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES {
|
||||
if ui.selectable_label(preset == *label, *label).clicked() {
|
||||
d.fields.insert("preset".into(), label.to_string());
|
||||
d.fields.insert("w".into(), format!("{:.2}", w / 72.0));
|
||||
d.fields.insert("h".into(), format!("{:.2}", h / 72.0));
|
||||
put_slide_dim(d, "w", *w, ppu);
|
||||
put_slide_dim(d, "h", *h, ppu);
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = combo.response.labelled_by(label.id);
|
||||
field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0));
|
||||
field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0));
|
||||
let (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0));
|
||||
let abbr = unit_abbr(len);
|
||||
let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h"));
|
||||
field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed);
|
||||
field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed);
|
||||
let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Orientation:");
|
||||
let portrait = ui.radio(h > w, "Portrait").clicked();
|
||||
let landscape = ui.radio(w >= h, "Landscape").clicked();
|
||||
// Picking the other orientation swaps the width and height fields.
|
||||
if portrait && w > h || landscape && h > w {
|
||||
d.fields.insert("w".into(), format!("{h:.2}"));
|
||||
d.fields.insert("h".into(), format!("{w:.2}"));
|
||||
put_slide_dim(d, "w", h, ppu);
|
||||
put_slide_dim(d, "h", w, ppu);
|
||||
}
|
||||
});
|
||||
let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false);
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let (w, h) = (num(d, "w", cur.width / 72.0) * 72.0, num(d, "h", cur.height / 72.0) * 72.0);
|
||||
let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height));
|
||||
run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }}));
|
||||
}
|
||||
ok || cancel
|
||||
@@ -1645,9 +1684,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
app.set_status("Tick at least one slide to insert".to_string());
|
||||
return false;
|
||||
}
|
||||
app.reuse_slides(&path, keep, &picked);
|
||||
// A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar.
|
||||
let source = d.params.get("source").cloned().unwrap_or_default();
|
||||
return app.reuse_slides(&source, keep, &picked);
|
||||
}
|
||||
ok || cancel
|
||||
cancel
|
||||
}
|
||||
"renameSection" | "renameShape" | "renameLayout" => {
|
||||
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -2056,7 +2097,21 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let (cur, np) = selected_language(app);
|
||||
let lang = d.get("lang", &cur);
|
||||
let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1";
|
||||
ui.label("Mark selected text as:");
|
||||
// The selected text, or every text in the presentation (all there is with nothing selected).
|
||||
let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok();
|
||||
let mut all = !has_sel || d.get("scope", "") == "all";
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Apply to:");
|
||||
if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() {
|
||||
d.fields.insert("scope".into(), "sel".into());
|
||||
all = false;
|
||||
}
|
||||
if ui.radio(all, "Whole presentation").clicked() {
|
||||
d.fields.insert("scope".into(), "all".into());
|
||||
all = true;
|
||||
}
|
||||
});
|
||||
ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" });
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
// The selection's own language first when the list doesn't have it.
|
||||
let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone()));
|
||||
@@ -2080,7 +2135,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let lang = d.get("lang", &cur);
|
||||
run(app, "format.language", json!({"lang": lang, "noProof": no_proof}));
|
||||
run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
@@ -2307,6 +2362,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
d.fields.remove("uiScale");
|
||||
}
|
||||
// PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Measurement units:");
|
||||
let units = &mut app.session.prefs.units;
|
||||
ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches");
|
||||
ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters");
|
||||
});
|
||||
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
|
||||
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
|
||||
ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid");
|
||||
|
||||
+141
-7
@@ -36,6 +36,8 @@ pub mod theme;
|
||||
pub mod thumbs;
|
||||
pub mod toast;
|
||||
pub mod uifonts;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod updates_ui;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -439,6 +441,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
"{layout?, perPage?, slides?, includeHidden?, frame?, paper?: letter|a4|legal|a3, orientation?: portrait|landscape}",
|
||||
),
|
||||
("app.about", "About DeckCraft", None, "{}"),
|
||||
("app.checkUpdates", "Check for Updates…", None, "{}"),
|
||||
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
|
||||
("app.openDialog", "Open…", None, "{purpose?}"),
|
||||
@@ -534,6 +537,8 @@ pub struct SlideApp {
|
||||
pub show: Option<show::Show>,
|
||||
/// Current slide's animations playing on the editor canvas (not the full-screen show).
|
||||
anim_preview: Option<AnimPreview>,
|
||||
/// Where the Reuse Slides picker inserts: the presentation and slide current when it opened.
|
||||
reuse_target: Option<FileTarget>,
|
||||
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
||||
pub media: media::MediaHost,
|
||||
pub status: Option<(String, f64)>,
|
||||
@@ -592,6 +597,8 @@ pub struct SlideApp {
|
||||
/// creating or closing a presentation leaves it.
|
||||
pub backstage: Option<backstage::Backstage>,
|
||||
pending_urls: Vec<String>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime,
|
||||
/// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.
|
||||
pending_copy: Option<String>,
|
||||
/// Bounded screenshot completion slots for the control channel (egui 0.37 callbacks).
|
||||
@@ -806,6 +813,7 @@ impl SlideApp {
|
||||
thesaurus_query: String::new(),
|
||||
show: None,
|
||||
anim_preview: None,
|
||||
reuse_target: None,
|
||||
media: media::MediaHost::new(audio_out),
|
||||
status: None,
|
||||
opening: None,
|
||||
@@ -834,6 +842,8 @@ impl SlideApp {
|
||||
integrated_titlebar: false,
|
||||
backstage: None,
|
||||
pending_urls: vec![],
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime::default(),
|
||||
pending_copy: None,
|
||||
capture: capture::Capture::default(),
|
||||
styled: false,
|
||||
@@ -878,6 +888,13 @@ impl SlideApp {
|
||||
self.status = Some((msg.into(), now_ms()));
|
||||
}
|
||||
|
||||
/// Turn on the automatic check for a newer release at start-up (desktop shell only; tests and
|
||||
/// the web build never touch the network on their own).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn enable_update_checks(&mut self) {
|
||||
updates_ui::enable(self);
|
||||
}
|
||||
|
||||
/// A message that must be seen wherever the user is: a toast, which also shows over the start
|
||||
/// screen and the File tab, where there is no status bar.
|
||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||
@@ -1352,6 +1369,12 @@ impl SlideApp {
|
||||
return Ok(json!({"printed": self.print_pdf(p.clone())}));
|
||||
}
|
||||
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
|
||||
"app.checkUpdates" => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::request_check(self);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err("updates are handled by the browser: reload the page".into());
|
||||
}
|
||||
"app.palette" => self.palette = Some((String::new(), 0)),
|
||||
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
|
||||
"app.openDialog" => self.pick_and_open(),
|
||||
@@ -1609,22 +1632,55 @@ impl SlideApp {
|
||||
return;
|
||||
};
|
||||
let Some(path) = pick("open") else { return };
|
||||
// Read the file once: the list shown and the slides inserted come from the same bytes, even
|
||||
// if the file changes while the dialog is open. Without a reader the path is used as is.
|
||||
let mut source = json!({});
|
||||
match self.services.read.as_ref().map(|r| r(&path)) {
|
||||
Some(Ok(bytes)) => {
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
source["name"] = json!(name);
|
||||
source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes));
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
self.set_status(e.to_string());
|
||||
return;
|
||||
}
|
||||
None => source["path"] = json!(path),
|
||||
}
|
||||
// Offer the file's slides to choose from; all of them are ticked to begin with.
|
||||
match self.session.execute("slide.reuseList", &json!({"path": path})) {
|
||||
match self.session.execute("slide.reuseList", &source) {
|
||||
Ok(list) => {
|
||||
let mut d = dialogs::Dialog::new("reuseSlides");
|
||||
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
|
||||
d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]});
|
||||
self.reuse_target = Some(self.capture_file_target());
|
||||
self.dialog = Some(d);
|
||||
}
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the slides `picked` (indices) of the presentation at `path` after the current slide.
|
||||
pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) {
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) {
|
||||
Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])),
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
/// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that
|
||||
/// `slide.reuseList` listed) after the slide that was current when the picker opened, in that
|
||||
/// presentation, even if another one is active now; if it is closed or the slide is gone,
|
||||
/// nothing is inserted. Returns whether they were inserted.
|
||||
pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool {
|
||||
let mut args = if source.is_object() { source.clone() } else { json!({}) };
|
||||
args["keepFormatting"] = json!(keep);
|
||||
args["slides"] = json!(picked);
|
||||
let result = match self.reuse_target {
|
||||
Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args),
|
||||
_ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.reuse_target = None;
|
||||
self.set_status(format!("Inserted {} slides", picked.len()));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1982,6 +2038,8 @@ impl SlideApp {
|
||||
if !self.synthetic_batch_is_live(ctx) {
|
||||
self.drain_control(ctx);
|
||||
self.poll_opening();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::tick(self, ctx);
|
||||
// Inbox results are host file bytes and do not need fonts or a painted frame. A
|
||||
// window that starts hidden must still apply a file picked before its first UI pass.
|
||||
self.drain_inbox();
|
||||
@@ -3312,6 +3370,8 @@ impl SlideApp {
|
||||
toast::note_missing_fonts(self);
|
||||
toast::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::show(self, &ctx);
|
||||
menus::palette(self, &ctx);
|
||||
for url in std::mem::take(&mut self.pending_urls) {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
@@ -3513,6 +3573,8 @@ mod last_color_tests;
|
||||
#[cfg(test)]
|
||||
mod replacefonts_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod slide_size_tests;
|
||||
#[cfg(test)]
|
||||
mod split_button_tests;
|
||||
@@ -3520,6 +3582,8 @@ mod split_button_tests;
|
||||
mod texture_limit_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_soak_tests;
|
||||
#[cfg(test)]
|
||||
mod units_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "datetime_tests.rs"]
|
||||
@@ -4532,6 +4596,76 @@ mod tests {
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
|
||||
}
|
||||
|
||||
/// Review > Language > Whole presentation marks every text box, not only the selected one.
|
||||
#[test]
|
||||
fn the_language_dialog_marks_the_whole_presentation() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let s = &mut harness.state_mut().session;
|
||||
let title = s.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hola"})).unwrap();
|
||||
s.execute("slide.new", &json!({"title": "Adiós"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
||||
harness.run_steps(4);
|
||||
harness.get_by_label("Whole presentation").click();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Español (España)").click();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("OK").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none());
|
||||
let d = harness.state().session.active().unwrap();
|
||||
for sl in &d.doc.slides {
|
||||
let body = sl.shapes[0].text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES"));
|
||||
}
|
||||
}
|
||||
|
||||
/// With nothing selected, Review > Language applies to the whole presentation without
|
||||
/// choosing it, and Selected text can't be picked.
|
||||
#[test]
|
||||
fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let s = &mut harness.state_mut().session;
|
||||
let title = s.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hola"})).unwrap();
|
||||
s.execute("slide.new", &json!({"title": "Adiós"})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty());
|
||||
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
||||
harness.run_steps(4);
|
||||
assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled());
|
||||
harness.get_by_label("Español (España)").click();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("OK").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none());
|
||||
let d = harness.state().session.active().unwrap();
|
||||
for sl in &d.doc.slides {
|
||||
let body = sl.shapes[0].text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
|
||||
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
|
||||
#[test]
|
||||
|
||||
@@ -736,6 +736,38 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
/// A video whose codec has no decoder (a VP8 WebM, #1237) still plays through the real
|
||||
/// insert + play path: its audio track sounds, and the insert said the video won't show.
|
||||
#[test]
|
||||
fn a_webm_with_an_undecodable_video_codec_still_plays_its_audio() {
|
||||
use deckcraft_engine::Session;
|
||||
let bytes = include_bytes!("../../media/tests/fixtures/clip-vp8.webm");
|
||||
let mut s = Session::with_new();
|
||||
let _ = s.execute("slide.new", &serde_json::json!({"layout": "blank"}));
|
||||
let r = s
|
||||
.execute("insert.video", &serde_json::json!({"name": "clip.webm", "data": deckcraft_engine::cmd::base64_encode(bytes)}))
|
||||
.expect("insert");
|
||||
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
|
||||
let w = r["warning"].as_str().expect("the insert says the video won't show");
|
||||
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
|
||||
let (uid, doc) = s.active().map(|d| (d.uid, d.doc.clone())).expect("document");
|
||||
let id = ShapeId(r["id"].as_u64().expect("id") as u32);
|
||||
let clip = find_clip(&doc, id, Some(0)).expect("clip");
|
||||
let mut host = MediaHost::new(None);
|
||||
host.play(uid, &doc, id, &clip, None, Owner::Show);
|
||||
assert!(host.player.wait_loaded(key(id), std::time::Duration::from_secs(5)), "decode");
|
||||
assert!(host.status(id).is_some_and(|s| s.error.is_none()), "Vorbis decodes");
|
||||
let mut buf = vec![0.0f32; 22_050];
|
||||
host.player.render(&mut buf, 1, 44_100);
|
||||
// The fixture's sine is −21 dB, so a peak near 0.09 means it is sounding.
|
||||
let peak = buf.iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
||||
assert!(peak > 0.04, "the audio track sounds: peak {peak}");
|
||||
assert!(host.videos.get(&id).is_some_and(|v| v.feed.is_none()), "VP8 opens no video feed");
|
||||
host.tick_clock(0.1, &mut s);
|
||||
host.sync_video_textures(&egui::Context::default());
|
||||
assert!(host.texture(id).is_none(), "no VP8 frame exists to show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_gifs_play_in_slide_space() {
|
||||
let mut doc = Presentation::default();
|
||||
|
||||
@@ -289,7 +289,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("Set Up Show…", "show.setup"),
|
||||
],
|
||||
),
|
||||
("Help", vec![("About DeckCraft", "app.about"), ("Command Palette", "app.palette")]),
|
||||
("Help", vec![("About DeckCraft", "app.about"), ("Check for Updates…", "app.checkUpdates"), ("Command Palette", "app.palette")]),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -331,10 +331,12 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let lock_id = egui::Id::unique("fmt-lock-aspect");
|
||||
let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false));
|
||||
let locked = lock;
|
||||
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| {
|
||||
let len = app.session.prefs.units;
|
||||
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| {
|
||||
let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) };
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
|
||||
if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) {
|
||||
if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, suffix, 70.0) {
|
||||
let mut p = json!({key: nv * unit});
|
||||
if cmd == "shape.resize" && locked {
|
||||
p["lockAspect"] = json!(true);
|
||||
@@ -344,16 +346,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
};
|
||||
ui.label(egui::RichText::new("Size").font(theme::bold(13.0)));
|
||||
fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0);
|
||||
fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0);
|
||||
fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0);
|
||||
fields(ui, "Height", "fh", x.h, "h", "shape.resize", false);
|
||||
fields(ui, "Width", "fw", x.w, "w", "shape.resize", false);
|
||||
fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true);
|
||||
if ui.checkbox(&mut lock, "Lock aspect ratio").changed() {
|
||||
ui.data_mut(|d| d.insert_temp(lock_id, lock));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Position").font(theme::bold(13.0)));
|
||||
fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0);
|
||||
fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0);
|
||||
fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false);
|
||||
fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false);
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0)));
|
||||
let id = ui.scope_id().with(("alt", uid, sh.id.0));
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Home › New Slide › Reuse Slides…: the dialog lists the slides of the picked file and inserts the
|
||||
//! ticked ones; what is inserted is what was listed, and a failed insert keeps the dialog open.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn deck_bytes(titles: &[&str]) -> Vec<u8> {
|
||||
let mut s = Session::with_new();
|
||||
for t in titles {
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).unwrap();
|
||||
}
|
||||
// Drop the new deck's empty first slide, so slide `i` is `titles[i]`.
|
||||
s.execute("slide.delete", &json!({"index": 0})).unwrap();
|
||||
deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap()
|
||||
}
|
||||
|
||||
/// An app whose open dialog picks `src.deckcraft`, read from the shared `file` bytes.
|
||||
fn harness(file: &Rc<RefCell<Vec<u8>>>) -> Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let f = Rc::clone(file);
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(|_| Some("/tmp/src.deckcraft".to_string()))),
|
||||
read: Some(Box::new(move |_| Ok(f.borrow().clone()))),
|
||||
..Services::default()
|
||||
};
|
||||
let app = SlideApp::new(s, services);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn titles(h: &Harness<'_, SlideApp>) -> Vec<String> {
|
||||
h.state().session.active().unwrap().doc.slides.iter().map(|s| s.title()).collect()
|
||||
}
|
||||
|
||||
fn open_dialog(h: &mut Harness<'_, SlideApp>) {
|
||||
h.state_mut().run("app.reuseSlidesDialog", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("reuseSlides"));
|
||||
}
|
||||
|
||||
fn press_insert(h: &mut Harness<'_, SlideApp>) {
|
||||
// The ribbon has an Insert tab too; the dialog's button is drawn last.
|
||||
h.query_all_by_label("Insert").last().expect("the dialog's Insert button").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_the_ticked_slides_and_closes() {
|
||||
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha", "Beta", "Gamma"])));
|
||||
let mut h = harness(&file);
|
||||
open_dialog(&mut h);
|
||||
// Untick the middle slide.
|
||||
h.state_mut().dialog.as_mut().unwrap().fields.insert("off1".into(), "1".into());
|
||||
h.run_steps(2);
|
||||
press_insert(&mut h);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let t = titles(&h);
|
||||
assert!(t.iter().any(|x| x == "Alpha") && t.iter().any(|x| x == "Gamma") && !t.iter().any(|x| x == "Beta"), "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_what_was_listed_even_if_the_file_changed_meanwhile() {
|
||||
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha", "Beta"])));
|
||||
let mut h = harness(&file);
|
||||
open_dialog(&mut h);
|
||||
// Someone replaces the file while the dialog is open: slide 0 is now a different one.
|
||||
*file.borrow_mut() = deck_bytes(&["Other", "Beta"]);
|
||||
h.state_mut().dialog.as_mut().unwrap().fields.insert("off1".into(), "1".into());
|
||||
h.run_steps(2);
|
||||
press_insert(&mut h);
|
||||
let t = titles(&h);
|
||||
assert!(t.iter().any(|x| x == "Alpha"), "{t:?}");
|
||||
assert!(!t.iter().any(|x| x == "Other"), "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_into_the_presentation_the_picker_was_opened_for() {
|
||||
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha"])));
|
||||
let mut h = harness(&file);
|
||||
open_dialog(&mut h);
|
||||
let first = h.state().session.active().unwrap().uid;
|
||||
// Another presentation becomes active while the dialog is open.
|
||||
h.state_mut().session.execute("file.new", &json!({})).unwrap();
|
||||
let second = h.state().session.active().unwrap().uid;
|
||||
assert_ne!(first, second);
|
||||
press_insert(&mut h);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let s = &h.state().session;
|
||||
let doc = |uid: u64| s.documents().iter().find(|d| d.uid == uid).unwrap();
|
||||
assert!(doc(first).doc.slides.iter().any(|x| x.title() == "Alpha"), "inserted where the picker was opened");
|
||||
assert_eq!(doc(second).doc.slides.len(), 1, "the other presentation is untouched");
|
||||
assert_eq!(s.active().unwrap().uid, second, "the view stays on the presentation the user moved to");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_inserted_once_that_presentation_is_closed() {
|
||||
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha"])));
|
||||
let mut h = harness(&file);
|
||||
open_dialog(&mut h);
|
||||
h.state_mut().session.execute("file.new", &json!({})).unwrap();
|
||||
h.state_mut().session.close_document(0);
|
||||
press_insert(&mut h);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert!(!titles(&h).iter().any(|x| x == "Alpha"), "{:?}", titles(&h));
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("reuseSlides"), "the dialog stays open with the reason");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_insert_keeps_the_dialog_open() {
|
||||
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha"])));
|
||||
let mut h = harness(&file);
|
||||
open_dialog(&mut h);
|
||||
let before = titles(&h);
|
||||
// Offer a slide the file doesn't have, so the insert is refused.
|
||||
if let Some(d) = h.state_mut().dialog.as_mut()
|
||||
&& let Some(a) = d.params["slides"].as_array_mut()
|
||||
{
|
||||
a.push(json!({"index": 9, "title": "Ghost", "hidden": false}));
|
||||
}
|
||||
h.run_steps(2);
|
||||
press_insert(&mut h);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("reuseSlides"), "the dialog closed on a failed insert");
|
||||
assert_eq!(titles(&h), before);
|
||||
}
|
||||
@@ -2707,16 +2707,20 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.label("Width:");
|
||||
return;
|
||||
};
|
||||
let len = app.session.prefs.units;
|
||||
let (ppu, suffix) = (len.points_per_unit(), len.suffix());
|
||||
// " cm" needs more room than the inch mark.
|
||||
let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Height:");
|
||||
if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / 72.0, 0.1, "\"", 54.0) {
|
||||
run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0}));
|
||||
if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) {
|
||||
run(app, "shape.resize", json!({"h": v.max(0.0) * ppu}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Width: ");
|
||||
if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / 72.0, 0.1, "\"", 54.0) {
|
||||
run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0}));
|
||||
if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) {
|
||||
run(app, "shape.resize", json!({"w": v.max(0.0) * ppu}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,11 +127,12 @@ mod tests {
|
||||
fn saving_round_trips_and_leaves_no_temporary_file() {
|
||||
let d = dir("save");
|
||||
let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() };
|
||||
let prefs = Prefs { author: "Ann".into(), ..Default::default() };
|
||||
let prefs = Prefs { author: "Ann".into(), units: deckcraft_geom::Unit::Centimeters, ..Default::default() };
|
||||
save(&d.join("nested"), &ui, &prefs);
|
||||
let (u, p) = load(&d.join("nested"));
|
||||
assert_eq!(u.unwrap().zoom, Some(1.5));
|
||||
assert_eq!(p.unwrap().author, "Ann");
|
||||
let p = p.unwrap();
|
||||
assert_eq!((p.author.as_str(), p.units), ("Ann", deckcraft_geom::Unit::Centimeters));
|
||||
let names: Vec<String> =
|
||||
std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}");
|
||||
|
||||
@@ -957,6 +957,17 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm {
|
||||
actions.push(show.state.prev(&doc));
|
||||
}
|
||||
// Ctrl+wheel (Cmd+wheel on a Mac) zooms around the pointer, as in PowerPoint (#1286). egui
|
||||
// keeps it out of `smooth_scroll_delta`, so it does not also turn the page below.
|
||||
let line_points = ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
if !show.grid
|
||||
&& !show.ended
|
||||
&& show.blank.is_none()
|
||||
&& let Some(p) = pointer
|
||||
&& let Some(lines) = ctx.input(|i| wheel_zoom_lines(&i.events, line_points))
|
||||
{
|
||||
show.wheel_zoom(srect, p, lines);
|
||||
}
|
||||
// A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick
|
||||
// there stays on the slide. Pinch zoom is not a show gesture.
|
||||
if !show.pen
|
||||
@@ -1702,6 +1713,30 @@ impl Show {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd+wheel: `lines` notches in (positive) or out around the slide point under `p`.
|
||||
/// Back to the whole slide leaves the zoom. A pointer off the slide does nothing.
|
||||
fn wheel_zoom(&mut self, srect: Rect, p: Pos2, lines: f32) {
|
||||
let uv = self.zoom_uv();
|
||||
let Some((nx, ny)) = norm_on_slide(srect, uv, p) else { return };
|
||||
let from = self.zoom.map_or(1.0, |z| z.scale);
|
||||
let scale = (from * 2f32.powf(lines / WHEEL_NOTCHES_PER_DOUBLING)).min(WHEEL_ZOOM_MAX);
|
||||
if !scale.is_finite() {
|
||||
return;
|
||||
}
|
||||
self.zoom_arm = false;
|
||||
if scale <= 1.0 + 1e-3 {
|
||||
self.zoom = None;
|
||||
return;
|
||||
}
|
||||
// The new window puts (nx, ny) at the same place on screen as before.
|
||||
let w = 1.0 / scale;
|
||||
let fx = (p.x - srect.min.x) / srect.width();
|
||||
let fy = (p.y - srect.min.y) / srect.height();
|
||||
let x = (nx - fx * w).clamp(0.0, 1.0 - w);
|
||||
let y = (ny - fy * w).clamp(0.0, 1.0 - w);
|
||||
self.zoom = Some(Zoom { nx: x + w * 0.5, ny: y + w * 0.5, scale });
|
||||
}
|
||||
|
||||
/// Esc closes the See All Slides of the window it was pressed in, then a zoom, and only then leaves the show.
|
||||
fn back_out(&mut self, presenter: bool) -> bool {
|
||||
if presenter && self.presenter_grid {
|
||||
@@ -1815,6 +1850,10 @@ impl Show {
|
||||
const GOTO_HOLD: f64 = 3.0;
|
||||
const GOTO_DIGITS: usize = 4;
|
||||
const ZOOM_SCALE: f32 = 2.0;
|
||||
/// Four notches double the zoom, so the wheel passes through the click-zoom's 2× on its way to 4×.
|
||||
const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
|
||||
/// PowerPoint's slide show magnifier goes up to 400%.
|
||||
const WHEEL_ZOOM_MAX: f32 = 4.0;
|
||||
const GRID_CAP: usize = 48;
|
||||
const TOOL_BAR_W: f32 = 520.0;
|
||||
const NOTES_DEFAULT: f32 = 18.0;
|
||||
@@ -1932,6 +1971,26 @@ fn zoom_uv(nx: f32, ny: f32, scale: f32) -> Rect {
|
||||
Rect::from_min_size(pos2(x, y), vec2(w, w))
|
||||
}
|
||||
|
||||
/// The Ctrl/Cmd+wheel movement this frame in wheel lines (up is positive), `None` when there was
|
||||
/// none. Read from the raw events: egui's `zoom_delta` also carries the trackpad pinch, which is
|
||||
/// not a show gesture.
|
||||
fn wheel_zoom_lines(events: &[egui::Event], line_points: f32) -> Option<f32> {
|
||||
let mut lines = None;
|
||||
for event in events {
|
||||
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event
|
||||
&& (modifiers.ctrl || modifiers.command)
|
||||
{
|
||||
let along = if delta.x.abs() > delta.y.abs() { delta.x } else { delta.y };
|
||||
let n = match unit {
|
||||
egui::MouseWheelUnit::Point => along / line_points.max(1.0),
|
||||
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => along,
|
||||
};
|
||||
*lines.get_or_insert(0.0) += if n.is_finite() { n } else { 0.0 };
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn zoom_frame(slide: Rect, uv: Rect) -> Rect {
|
||||
Rect::from_min_size(
|
||||
pos2(slide.min.x + uv.min.x * slide.width(), slide.min.y + uv.min.y * slide.height()),
|
||||
@@ -2861,6 +2920,132 @@ mod tests {
|
||||
assert!((back.0 - 0.5).abs() < 1e-3 && (back.1 - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
fn near(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-3
|
||||
}
|
||||
|
||||
/// #1286: Ctrl+wheel zooms around the pointer, four notches per doubling, up to 4× (the most
|
||||
/// PowerPoint's magnifier goes); zooming back to the whole slide leaves the zoom.
|
||||
#[test]
|
||||
fn wheel_zoom_keeps_the_point_under_the_pointer_and_stops_at_4x() {
|
||||
let doc = deckcraft_model::Presentation::default();
|
||||
let mut show = Show::new(&doc, 0, false, false);
|
||||
// A 16:9 slide, not at the origin, and a pointer off its centre.
|
||||
let srect = Rect::from_min_size(pos2(40.0, 30.0), vec2(320.0, 180.0));
|
||||
let p = pos2(40.0 + 320.0 * 0.2, 30.0 + 180.0 * 0.3);
|
||||
show.wheel_zoom(srect, p, 4.0);
|
||||
let (_, _, scale) = show.zoom_view().expect("zoomed in");
|
||||
assert!(near(scale, 2.0), "four notches double, got {scale}");
|
||||
// The window keeps the slide's shape: half the width and half the height of the slide.
|
||||
let uv = show.zoom_uv();
|
||||
assert!(near(uv.width(), 0.5) && near(uv.height(), 0.5), "{uv:?}");
|
||||
assert!(near(uv.min.x, 0.2 - 0.5 * 0.2) && near(uv.min.y, 0.3 - 0.5 * 0.3), "{uv:?}");
|
||||
let under = norm_on_slide(srect, uv, p).expect("on the slide");
|
||||
assert!(near(under.0, 0.2) && near(under.1, 0.3), "the point under the pointer moved to {under:?}");
|
||||
|
||||
// Again from the zoomed view: the same slide point stays under the pointer.
|
||||
show.wheel_zoom(srect, p, 2.0);
|
||||
let (_, _, scale) = show.zoom_view().expect("zoomed in");
|
||||
assert!(near(scale, 2f32.powf(1.5)), "{scale}");
|
||||
let under = norm_on_slide(srect, show.zoom_uv(), p).expect("on the slide");
|
||||
assert!(near(under.0, 0.2) && near(under.1, 0.3), "{under:?}");
|
||||
|
||||
show.wheel_zoom(srect, p, 40.0);
|
||||
let (_, _, scale) = show.zoom_view().expect("zoomed in");
|
||||
assert!(near(scale, 4.0), "capped at 4×, got {scale}");
|
||||
assert!(near(show.zoom_uv().width(), 0.25));
|
||||
|
||||
show.wheel_zoom(srect, p, -7.0);
|
||||
assert!(show.zoom_view().is_some(), "one notch short of the whole slide is still zoomed");
|
||||
show.wheel_zoom(srect, p, -1.0);
|
||||
assert!(show.zoom_view().is_none(), "back to the whole slide leaves the zoom");
|
||||
assert_eq!(show.zoom_uv(), unit_uv());
|
||||
show.wheel_zoom(srect, p, -3.0);
|
||||
assert!(show.zoom_view().is_none(), "zooming out of the whole slide does nothing");
|
||||
|
||||
// Off the slide (the letterbox) the wheel does not zoom.
|
||||
show.wheel_zoom(srect, pos2(10.0, 10.0), 4.0);
|
||||
assert!(show.zoom_view().is_none());
|
||||
// An armed click-zoom gives way to the wheel.
|
||||
show.zoom_arm = true;
|
||||
show.wheel_zoom(srect, p, 4.0);
|
||||
assert!(!show.zoom_arm && show.zoom_view().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_reads_only_the_wheel_with_ctrl_or_cmd() {
|
||||
let wheel = |unit, y: f32, modifiers| egui::Event::MouseWheel {
|
||||
unit,
|
||||
delta: vec2(0.0, y),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
modifiers,
|
||||
};
|
||||
use egui::MouseWheelUnit::{Line, Point};
|
||||
assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::CTRL)], 40.0), Some(1.0));
|
||||
assert_eq!(wheel_zoom_lines(&[wheel(Line, -2.0, egui::Modifiers::COMMAND)], 40.0), Some(-2.0));
|
||||
assert_eq!(wheel_zoom_lines(&[wheel(Point, 20.0, egui::Modifiers::CTRL), wheel(Point, 20.0, egui::Modifiers::CTRL)], 40.0), Some(1.0));
|
||||
assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::NONE)], 40.0), None, "a plain wheel turns the page");
|
||||
assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::SHIFT)], 40.0), None);
|
||||
assert_eq!(wheel_zoom_lines(&[egui::Event::Zoom(2.0)], 40.0), None, "a pinch is not a show gesture");
|
||||
assert_eq!(wheel_zoom_lines(&[wheel(Line, f32::NAN, egui::Modifiers::CTRL)], 40.0), Some(0.0));
|
||||
}
|
||||
|
||||
/// A running show on the first of three slides, with presenter view off.
|
||||
fn show_of_three() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let mut app = SlideApp::new(session, crate::Services::default());
|
||||
app.ui.presenter_view = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(0, false);
|
||||
harness.run_steps(2);
|
||||
harness
|
||||
}
|
||||
|
||||
fn show_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, at: Pos2, y: f32, modifiers: egui::Modifiers) {
|
||||
harness.input_mut().events.push(egui::Event::PointerMoved(at));
|
||||
harness.input_mut().events.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, y),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
modifiers,
|
||||
});
|
||||
harness.run_steps(40);
|
||||
}
|
||||
|
||||
/// #1286: in the show, Ctrl+wheel (Cmd+wheel on a Mac) zooms the slide and does not turn the
|
||||
/// page; the plain wheel still turns it.
|
||||
#[test]
|
||||
fn ctrl_wheel_zooms_the_show_and_the_plain_wheel_turns_the_page() {
|
||||
for held in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] {
|
||||
let mut h = show_of_three();
|
||||
let centre = pos2(500.0, 350.0);
|
||||
show_wheel(&mut h, centre, 4.0, held);
|
||||
let show = h.state().show.as_ref().expect("still showing");
|
||||
assert_eq!(show.state.slide, 0, "{held:?}: the zoom wheel does not turn the page");
|
||||
let (_, _, scale) = show.zoom_view().expect("zoomed in");
|
||||
assert!(near(scale, 2.0), "{held:?}: {scale}");
|
||||
|
||||
show_wheel(&mut h, centre, -4.0, held);
|
||||
let show = h.state().show.as_ref().expect("still showing");
|
||||
assert_eq!(show.state.slide, 0, "{held:?}: zooming out does not go back a page either");
|
||||
assert!(show.zoom_view().is_none(), "{held:?}: back to the whole slide");
|
||||
|
||||
show_wheel(&mut h, centre, -3.0, egui::Modifiers::NONE);
|
||||
assert_eq!(h.state().show.as_ref().expect("still showing").state.slide, 1, "the plain wheel turns the page");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_only_closes_the_grid_of_its_own_window() {
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
|
||||
@@ -232,8 +232,10 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
||||
p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let add = ui.input(|inp| inp.modifiers.command);
|
||||
if add {
|
||||
let (shift, add) = ui.input(|inp| (inp.modifiers.shift, inp.modifiers.command));
|
||||
if shift {
|
||||
let _ = app.run("slide.selectRange", json!({"to": i, "add": add}));
|
||||
} else if add {
|
||||
if let Some(id) = id {
|
||||
let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true}));
|
||||
}
|
||||
|
||||
@@ -179,3 +179,33 @@ fn ctrl_a_selects_every_slide() {
|
||||
let all = st.doc.slides.len();
|
||||
assert_eq!(st.selection.slides.len(), all, "Ctrl+A in the sorter selects every slide");
|
||||
}
|
||||
|
||||
fn selected(h: &Harness<'_, SlideApp>) -> Vec<usize> {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_and_ctrl_shift_clicks_select_ranges() {
|
||||
use egui::Modifiers;
|
||||
let mut h = harness(false);
|
||||
let click = |h: &mut Harness<'_, SlideApp>, n: usize, m: Modifiers| {
|
||||
h.get_by_label(&format!("Slide {n}")).click_modifiers(m);
|
||||
h.run_steps(2);
|
||||
};
|
||||
// Click slide 2, Shift+click slide 4: slides 2–4, slide 4 current.
|
||||
click(&mut h, 2, Modifiers::NONE);
|
||||
click(&mut h, 4, Modifiers::SHIFT);
|
||||
assert_eq!(selected(&h), vec![1, 2, 3]);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.slide, 3);
|
||||
// A second Shift+click extends from slide 2 again, not from slide 4.
|
||||
click(&mut h, 1, Modifiers::SHIFT);
|
||||
assert_eq!(selected(&h), vec![0, 1]);
|
||||
// Click 1, Ctrl+click 2, Ctrl+Shift+click 4: the range joins what is selected.
|
||||
click(&mut h, 1, Modifiers::NONE);
|
||||
click(&mut h, 3, Modifiers::COMMAND);
|
||||
click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT);
|
||||
assert_eq!(selected(&h), vec![0, 2, 3]);
|
||||
}
|
||||
|
||||
@@ -160,14 +160,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if resp.clicked() {
|
||||
let (shift, cmd) = ui.input(|i| (i.modifiers.shift, i.modifiers.command));
|
||||
if let Some(id) = id {
|
||||
if cmd {
|
||||
if shift {
|
||||
// Shift: the slides between the current one and this one. Ctrl+Shift keeps what is selected.
|
||||
let _ = app.run("slide.selectRange", json!({"to": i, "add": cmd}));
|
||||
} else if cmd {
|
||||
let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true}));
|
||||
} else if shift {
|
||||
let (a, b) = (cur.min(i), cur.max(i));
|
||||
let ids: Vec<u32> = doc.slides.iter().skip(a).take(b - a + 1).map(|s| s.id.0).collect();
|
||||
let _ = app.run("slide.selectSlides", json!({"ids": ids}));
|
||||
let _ = app.run("slide.go", json!({"index": i}));
|
||||
let _ = app.run("slide.selectSlides", json!({"ids": ids}));
|
||||
} else {
|
||||
let _ = app.run("slide.go", json!({"index": i}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Measurement units (#1326): with centimetres chosen in Preferences, the Format pane, the ribbon
|
||||
//! and the Slide Size dialog show and take centimetres, and the deck keeps its sizes in points.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_geom::Unit;
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
/// A blank slide with one 2" x 1.5" rectangle at (1", 0.5"), selected.
|
||||
fn app_with_shape(units: Unit) -> SlideApp {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [72.0, 36.0, 144.0, 108.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
s.prefs.units = units;
|
||||
SlideApp::new(s, Services::default())
|
||||
}
|
||||
|
||||
fn text_values(h: &Harness<'_, SlideApp>) -> Vec<String> {
|
||||
h.query_all_by_role(Role::TextInput).filter_map(|n| n.value()).collect()
|
||||
}
|
||||
|
||||
fn selected_xfrm(app: &SlideApp) -> deckcraft_geom::Xfrm {
|
||||
let st = app.session.active().unwrap();
|
||||
let sh = st.shape(*st.selection.shapes.first().unwrap()).unwrap();
|
||||
deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_format_pane_shows_and_takes_centimetres() {
|
||||
let mut app = app_with_shape(Unit::Centimeters);
|
||||
app.ui.pane = Some("format".into());
|
||||
app.ui.format_tab = "size".into();
|
||||
app.ui.ruler = true;
|
||||
app.ui.tab = "Shape Format".into();
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.tab = "Home".into();
|
||||
h.run_steps(3);
|
||||
let shown = text_values(&h);
|
||||
// 108 pt = 1.5" = 3.81 cm; 144 pt = 5.08 cm; 72 pt = 2.54 cm; 36 pt = 1.27 cm.
|
||||
for v in ["3.81 cm", "5.08 cm", "2.54 cm", "1.27 cm"] {
|
||||
assert!(shown.iter().any(|s| s == v), "{v} in {shown:?}");
|
||||
}
|
||||
assert!(shown.iter().all(|s| !s.ends_with('"')), "no inches left: {shown:?}");
|
||||
// Typing 2.54 into Width makes the shape one inch wide, not 2.54 inches.
|
||||
let width = h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("5.08 cm")).unwrap();
|
||||
width.focus();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text("2.54");
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let x = selected_xfrm(h.state());
|
||||
assert!((x.w - 72.0).abs() < 1e-6, "{x:?}");
|
||||
assert!((x.h - 108.0).abs() < 1e-6, "the height is untouched: {x:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_format_pane_keeps_inches_by_default() {
|
||||
let mut app = app_with_shape(Unit::default());
|
||||
app.ui.pane = Some("format".into());
|
||||
app.ui.format_tab = "size".into();
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
let shown = text_values(&h);
|
||||
assert!(shown.iter().any(|s| s == "2\""), "{shown:?}");
|
||||
assert!(shown.iter().all(|s| !s.ends_with("cm")), "{shown:?}");
|
||||
}
|
||||
|
||||
/// Shape Format › Size on the ribbon: the same unit as the pane (the pane stays closed here).
|
||||
#[test]
|
||||
fn the_ribbon_size_boxes_show_and_take_centimetres() {
|
||||
let mut app = app_with_shape(Unit::Centimeters);
|
||||
app.ui.tab = "Shape Format".into();
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
let shown = text_values(&h);
|
||||
assert!(shown.iter().any(|s| s == "3.81 cm") && shown.iter().any(|s| s == "5.08 cm"), "{shown:?}");
|
||||
let height = h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("3.81 cm")).unwrap();
|
||||
height.focus();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text("1.27");
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let x = selected_xfrm(h.state());
|
||||
assert!((x.h - 36.0).abs() < 1e-6 && (x.w - 144.0).abs() < 1e-6, "{x:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slide_size_dialog_shows_and_takes_centimetres() {
|
||||
let mut app = app_with_shape(Unit::Centimeters);
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("slideSize"));
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Width (cm):").is_some() && h.query_by_label("Height (cm):").is_some());
|
||||
assert!(h.query_by_label("Width (in):").is_none());
|
||||
// 960 x 540 pt = 33.87 x 19.05 cm.
|
||||
let shown = text_values(&h);
|
||||
assert!(shown.iter().any(|s| s == "33.87") && shown.iter().any(|s| s == "19.05"), "{shown:?}");
|
||||
// A 4:3 size typed in centimetres: 25.4 x 19.05 cm = 10" x 7.5" = 720 x 540 pt.
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
d.fields.insert("w".into(), "25.4".into());
|
||||
h.run_steps(1);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let size = h.state().session.active().unwrap().doc.slide_size;
|
||||
assert!((size.width - 720.0).abs() < 1e-6 && (size.height - 540.0).abs() < 1e-6, "{size:?}");
|
||||
}
|
||||
|
||||
/// OK with the shown values untouched keeps the exact size: 13.333" is shown rounded to 33.87 cm,
|
||||
/// which read back would make the slide 960.09 pt wide.
|
||||
#[test]
|
||||
fn ok_in_the_slide_size_dialog_without_edits_keeps_the_size() {
|
||||
for units in [Unit::Centimeters, Unit::Inches] {
|
||||
let mut app = app_with_shape(units);
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("slideSize"));
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let size = h.state().session.active().unwrap().doc.slide_size;
|
||||
assert_eq!((size.width, size.height), (960.0, 540.0), "{units:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn slide_size_dialog(units: Unit, w: f64, h: f64) -> Harness<'static, SlideApp> {
|
||||
let mut app = app_with_shape(units);
|
||||
app.session.execute("design.slideSize", &json!({"w": w, "h": h, "scale": "none"})).unwrap();
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("slideSize"));
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn ok_and_size(h: &mut Harness<'_, SlideApp>) -> (f64, f64) {
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let size = h.state().session.active().unwrap().doc.slide_size;
|
||||
(size.width, size.height)
|
||||
}
|
||||
|
||||
/// A preset is the preset's exact size, not its two-decimal rendering read back (33.87 cm would
|
||||
/// be 960.09 pt; in inches 16:9, A4, B4 and B5 drift the same way).
|
||||
#[test]
|
||||
fn a_slide_size_preset_gives_its_exact_size() {
|
||||
for (units, label, size) in [
|
||||
(Unit::Centimeters, "Widescreen (16:9)", (960.0, 540.0)),
|
||||
(Unit::Inches, "Widescreen (16:9)", (960.0, 540.0)),
|
||||
(Unit::Inches, "A4 Paper (210x297 mm)", (780.0, 540.0)),
|
||||
] {
|
||||
let mut h = slide_size_dialog(units, 720.0, 540.0);
|
||||
h.get_by_role_and_label(Role::ComboBox, "Slides sized for:").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(ok_and_size(&mut h), size, "{units:?} {label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Portrait swaps the exact width and height.
|
||||
#[test]
|
||||
fn portrait_swaps_the_exact_slide_size() {
|
||||
for units in [Unit::Centimeters, Unit::Inches] {
|
||||
let mut h = slide_size_dialog(units, 960.0, 540.0);
|
||||
h.get_by_label("Portrait").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(ok_and_size(&mut h), (540.0, 960.0), "{units:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_choose_the_units_and_cancel_puts_them_back() {
|
||||
let mut app = app_with_shape(Unit::Inches);
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("preferences"));
|
||||
let mut h = harness(app);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Inches").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Centimeters").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.units, Unit::Centimeters);
|
||||
h.query_all_by_role_and_label(Role::Button, "Cancel").next().unwrap().click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.units, Unit::Inches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ruler_counts_centimetres_in_halves_and_inches_in_quarters() {
|
||||
assert_eq!(crate::canvas::ruler_steps(Unit::Inches), (72.0, 4));
|
||||
let (cm, sub) = crate::canvas::ruler_steps(Unit::Centimeters);
|
||||
assert!((cm - 72.0 / 2.54).abs() < 1e-9 && sub == 2, "{cm} {sub}");
|
||||
// A hand-edited prefs.json can say points: the ruler stays in inches rather than a tick per point.
|
||||
assert_eq!(crate::canvas::ruler_steps(Unit::Points), (72.0, 4));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! One background check at start-up (and on demand from Help) for a newer release, and the modal
|
||||
//! prompt that offers to update and restart. The flow itself is `craft_update::updater::Updater`.
|
||||
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
|
||||
use crate::SlideApp;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub(crate) struct Runtime {
|
||||
updater: Updater,
|
||||
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
||||
enabled: bool,
|
||||
check_requested: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&craft_update::suite::DECKCRAFT, VERSION), enabled: false, check_requested: false }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enable(app: &mut SlideApp) {
|
||||
app.updates_rt.enabled = true;
|
||||
}
|
||||
|
||||
/// Help > Check for Updates…: runs a check and reports the outcome either way.
|
||||
pub(crate) fn request_check(app: &mut SlideApp) {
|
||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||
app.updates_rt.check_requested = true;
|
||||
app.notify("Checking for updates…");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let rt = &mut app.updates_rt;
|
||||
if std::mem::take(&mut rt.check_requested) {
|
||||
rt.updater.check_now(ctx);
|
||||
} else if rt.enabled {
|
||||
rt.updater.check_at_start(ctx);
|
||||
}
|
||||
rt.updater.tick(ctx);
|
||||
let outcome = match rt.updater.status() {
|
||||
Status::UpToDate => Some(format!("DeckCraft {VERSION} is up to date.")),
|
||||
Status::Failed(e) => Some(format!("Could not check for updates: {e}")),
|
||||
Status::Idle | Status::Checking => None,
|
||||
};
|
||||
if let Some(message) = outcome {
|
||||
rt.updater.dismiss_status();
|
||||
app.notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// The app's close path asks about unsaved work; the restart helper relaunches once the process exits.
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_offered_release_opens_the_prompt() {
|
||||
let mut app = SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!app.updates_rt.updater.prompt_open());
|
||||
app.updates_rt.updater.offer(craft_update::Release { tag: "v99.0.0".into(), ..Default::default() });
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
assert!(app.updates_rt.updater.prompt_open());
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -259,8 +259,9 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
|
||||
for Add to Dictionary. Red squiggles on the current slide come from a worker thread, with a
|
||||
context menu to correct, ignore or add; table cells and speaker notes are squiggled too, each
|
||||
with a right-click menu; repeated words (`the the`) are flagged with Delete Repeated Word;
|
||||
Review › Language marks the selection's proofing language or Do Not Check Spelling
|
||||
(`lang`/`noProof` round-trip in PPTX). No grammar checking. The web build checks on the UI
|
||||
Review › Language marks the selection's proofing language or Do Not Check Spelling, or the
|
||||
whole presentation's (`format.languageAll`: every run, notes, masters, layouts and the default
|
||||
text style) (`lang`/`noProof` round-trip in PPTX). No grammar checking. The web build checks on the UI
|
||||
thread a few milliseconds per frame. No dictionaries ship with DeckCraft, so without one
|
||||
installed (always on Windows and the web) only a short list of common misspellings is found.
|
||||
`review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Localization parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (one proofing language for the whole presentation; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
|
||||
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
|
||||
@@ -53,12 +54,15 @@ the next re-measure.
|
||||
PowerPoint checks spelling in dozens of languages and lets each run carry a proofing language.
|
||||
DeckCraft keeps `a:rPr/@lang` on round trip but its spelling check is a small English list of
|
||||
common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#proofing-spelling-thesaurus-autocorrect).
|
||||
Review › Language sets the proofing language of the selected text or, with *Whole presentation*,
|
||||
of every text in the file and the default for new text (`format.languageAll`).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. |
|
||||
| 2026-10-21 | minor | Review › Language can set the proofing language of the whole presentation |
|
||||
| 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret |
|
||||
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
|
||||
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Slide show and presenter view: parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
|
||||
rehearsal and recording. Transition and animation playback are in
|
||||
@@ -29,7 +29,7 @@ ready** (estimated), **10–16 h** to parity.
|
||||
| Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable. Click sequence since #116. Slide Show ▸ Show Media Controls (on by default) hides the playback bar. Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media` |
|
||||
| Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls |
|
||||
| See All Slides grid during the show | yes | yes: G, or All on the show bar; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown |
|
||||
| Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it | The presenter keeps the whole slide and draws a frame on the zoomed part |
|
||||
| Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it. Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4× (#1286) | The presenter keeps the whole slide and draws a frame on the zoomed part. The wheel zooms in the audience window only; no panning while zoomed |
|
||||
| Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt |
|
||||
| Laser pointer | yes | yes in the show | Cmd/Ctrl+L and the on-screen button draw the show's laser colour and leave no ink. The presenter window does not move that dot |
|
||||
| Subtitles and live captions | yes (cloud speech) | no | AI dimension |
|
||||
@@ -91,5 +91,6 @@ Reading View in a window with navigation: present (checklist row done).
|
||||
| 2026-10-14 | minor | A custom show plays only its slides, from the Custom Shows dialog, from Set Up Slide Show, or from a click action. Checklist breadth stays 66% |
|
||||
| 2026-10-14 | minor | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement and swap stay with #433. Checklist breadth stays 66% |
|
||||
| 2026-10-14 | minor | See All Slides (G) and click-to-zoom (Z) during the show. Presenter view opens that grid and sets black, white and zoom. Checklist breadth stays 66% |
|
||||
| 2026-10-24 | minor | Ctrl+wheel (Cmd+wheel on a Mac) zooms the show in and out around the pointer, up to 4×; the plain wheel still turns the page (#1286) |
|
||||
| 2026-10-23 | trivial | Presenter view draws the notes with their formatting (bullets, numbers, indents, bold/italic) instead of plain text (#1128) |
|
||||
| 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |
|
||||
|
||||
Reference in New Issue
Block a user