mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
Compare commits
93
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 | ||
|
|
14f40c408c | ||
|
|
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 |
@@ -17,12 +17,45 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Keep the early manifest preflight separate from the reusable workflow. The shared gate
|
||||
# also runs with require-craft-fonts: true, so the build script enforces actual required faces.
|
||||
# Keep this pin identical to gate, freebsd.yml and release.yml.
|
||||
font-pin:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
steps:
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Font pin gate
|
||||
env:
|
||||
# The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not
|
||||
# a proposed future SHA; update it together with all three workflow pins.
|
||||
PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
run: |
|
||||
manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt"
|
||||
if [ -f "$manifest" ]; then
|
||||
echo "craft-fonts pin carries the reviewed simplified-Chinese manifest."
|
||||
elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then
|
||||
echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts."
|
||||
else
|
||||
echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gate:
|
||||
needs: font-pin
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
require-craft-fonts: true
|
||||
packages: >-
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
|
||||
libgl1-mesa-dev
|
||||
|
||||
@@ -27,4 +27,8 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes
|
||||
# CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not
|
||||
# qualify the required Chinese input after the pin advances; that gate stays explicitly
|
||||
# pending in docs/egui-037-migration-plan.md section 5.1.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
|
||||
@@ -24,7 +24,10 @@ jobs:
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
|
||||
# CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese
|
||||
# manifest fails the release instead of silently skipping it.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
+21
-5
@@ -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 |
|
||||
@@ -41,8 +41,9 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
|
||||
| Asset | Author | Source | Licence | Used for |
|
||||
|---|---|---|---|---|
|
||||
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
|
||||
| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs |
|
||||
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
|
||||
| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs |
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
|
||||
|
||||
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
|
||||
|
||||
@@ -58,9 +59,14 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria |
|
||||
| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New |
|
||||
| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text |
|
||||
| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text |
|
||||
| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text |
|
||||
| NanumGothic | OFL-1.1 | Korean text and interface fallback |
|
||||
| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback |
|
||||
|
||||
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
|
||||
Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest
|
||||
provides one) and BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
@@ -75,3 +81,13 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
|
||||
|
||||
### Optional native Chinese fallback
|
||||
|
||||
Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present.
|
||||
This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain
|
||||
in craft-fonts, including the LXGW Reserved Font Names and original Klee notice.
|
||||
|
||||
| Family | Author | Source | Licence | Use |
|
||||
|---|---|---|---|---|
|
||||
| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` |
|
||||
|
||||
Generated
+693
-236
File diff suppressed because it is too large
Load Diff
+21
-4
@@ -61,15 +61,17 @@ 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"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = { version = "0.37", default-features = false }
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
web-time = "1.1"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -104,3 +106,18 @@ debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -18,7 +18,13 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in
|
||||
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
|
||||
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
|
||||
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
|
||||
Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2,
|
||||
Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release
|
||||
packages (the exact font files and OFL texts are retained in craft-fonts releases). Native
|
||||
builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW
|
||||
WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font
|
||||
and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is
|
||||
not in the web payload.
|
||||
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
|
||||
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
|
||||
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
|
||||
@@ -80,3 +86,20 @@ Code dependencies
|
||||
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
|
||||
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
|
||||
and hashes are in ATTRIBUTION.md.
|
||||
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
+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 |
|
||||
|
||||
@@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
|
||||
const CANVAS_ID: &str = "deckcraft_canvas";
|
||||
const LOADING_ID: &str = "deckcraft_loading";
|
||||
|
||||
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
|
||||
/// build the sample or blank deck, and replace the loading element when launch finishes.
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -36,6 +38,7 @@ pub fn start() {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -74,11 +77,17 @@ struct WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
// Capture the document and slide now. The bytes arrive later, after the user may
|
||||
// have switched slides.
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
/// and deliver the id, name, and bytes through the inbox.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
|
||||
// fresh input, and capture the document/slide before the asynchronous read finishes.
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
let id = self.app.begin_external_file("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
@@ -97,11 +106,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -42,8 +42,9 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.1, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Forward native raw input to the wrapped application once per pass.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -180,10 +181,19 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
}
|
||||
|
||||
/// Install logging, parse startup arguments and environment, launch the native app, and
|
||||
/// propagate an eframe startup failure.
|
||||
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 => {
|
||||
@@ -255,8 +265,10 @@ fn main() -> eframe::Result {
|
||||
"DeckCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
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)]
|
||||
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::{
|
||||
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
|
||||
};
|
||||
|
||||
use crate::*;
|
||||
use crate::{
|
||||
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
|
||||
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
|
||||
};
|
||||
|
||||
const W: f64 = 960.0;
|
||||
const H: f64 = 540.0;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
|
||||
|
||||
use crate::*;
|
||||
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
|
||||
|
||||
fn shape(id: u32) -> Shape {
|
||||
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
|
||||
|
||||
@@ -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::*;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ use deckcraft_geom::{Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, edit_geometry, has_doc, has_selection, has_slide, has_two, id_param, ok, str_param, targets,
|
||||
usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
|
||||
use image::{GrayImage, Rgba, RgbaImage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_selection, str_param, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The effects offered: `a14` element name and label.
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, f64_or, has_selection, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Larger pictures are refused rather than matted slowly (16 megapixels).
|
||||
@@ -184,7 +184,12 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use crate::cmd::base64_encode;
|
||||
|
||||
/// A red disc on a white-to-grey gradient.
|
||||
fn disc() -> image::RgbaImage {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
use crate::Session;
|
||||
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, f64_param, has_doc, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -10,7 +10,10 @@ use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builti
|
||||
use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
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};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1344,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 =
|
||||
@@ -1366,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) {
|
||||
@@ -1619,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));
|
||||
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, can_redo, can_undo, cmd, has_doc, has_selection, has_slide, id_param, ids_param, ok,
|
||||
richclip, str_param, targets, xfrm_of,
|
||||
};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -318,7 +321,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;
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -6,10 +6,15 @@ 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::*;
|
||||
use super::{
|
||||
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text,
|
||||
has_text_or_shapes, ids_param, ok, str_param,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
use deckcraft_model::ShapeId;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -60,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",
|
||||
@@ -406,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());
|
||||
}
|
||||
@@ -424,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())
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -8,7 +8,10 @@ use deckcraft_model::text::{AutoFit, RunKind, TextBody};
|
||||
use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, always, bad, base64_decode, base64_encode, bool_or, bool_param, cmd, color_value, edit_shapes, has_doc, has_selection,
|
||||
has_slide, has_text_or_shapes, ok, rect_param, str_param, usize_param, xfrm_of,
|
||||
};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -149,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"))?;
|
||||
@@ -1278,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(|| {
|
||||
|
||||
@@ -4,7 +4,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, cmd, edit_shapes, has_doc, targets};
|
||||
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -179,7 +179,11 @@ fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::ShapeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::base64_encode;
|
||||
use crate::{MediaStatus, Session, UiRequest};
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let rate = 8000u32;
|
||||
@@ -286,4 +290,16 @@ mod tests {
|
||||
assert_eq!(r["playable"], false);
|
||||
assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() {
|
||||
let mut s = Session::with_new();
|
||||
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
|
||||
let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm");
|
||||
let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert");
|
||||
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
|
||||
let w = r["warning"].as_str().expect("the VP8 track can't be shown");
|
||||
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
|
||||
assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
|
||||
use i_overlay::float::single::SingleFloatOverlay;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_two, str_param, targets, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Closed polygons (slide points) describing a filled area (non-zero winding).
|
||||
@@ -255,7 +255,12 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Area, area_of, area_size, letters_of, merge_areas};
|
||||
use crate::Session;
|
||||
use crate::cmd::base64_encode;
|
||||
|
||||
fn square(x: f64, y: f64, s: f64) -> Area {
|
||||
vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, cmd, edit_shapes, has_selection, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
|
||||
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, cmd, f64_param, has_selection, id_param, ok, str_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::style::{PictureAdjust, PictureMode};
|
||||
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, current_shapes, edit_shapes, has_selection, str_param, targets, text, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
use deckcraft_model::Comment;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, f64_or, has_doc, has_slide, has_text, id_param, ok, str_param, usize_param,
|
||||
};
|
||||
use crate::{Result, Session, Target, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -430,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()?;
|
||||
@@ -843,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");
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_slide, id_param, ids_param, ok};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
|
||||
@@ -8,7 +8,10 @@ use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop,
|
||||
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
|
||||
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1519,6 +1522,8 @@ fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use deckcraft_model::ShapeId;
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, has_doc, has_slide, ok, opt_usize_param, str_param, usize_param};
|
||||
use crate::{Result, Session, Target};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use deckcraft_model::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, current_shapes, has_selection, shape_of, str_param, targets, xfrm_of};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Deepest outline level the Text Pane allows.
|
||||
|
||||
@@ -4,28 +4,49 @@ use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::{Shape, ShapeKind, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, bad, bool_or, bool_param, cmd, color_param, edit_shapes, f64_param, has_selection, has_slide, id_param, ok, opt_usize_param,
|
||||
str_param, table_drawn_heights, xfrm_of,
|
||||
};
|
||||
use crate::{DocState, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("table.insertRowAbove", "Insert Above", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "rowAbove"
|
||||
s,
|
||||
p,
|
||||
"rowAbove",
|
||||
"table.insertRowAbove"
|
||||
)),
|
||||
cmd!("table.insertRowBelow", "Insert Below", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "rowBelow"
|
||||
s,
|
||||
p,
|
||||
"rowBelow",
|
||||
"table.insertRowBelow"
|
||||
)),
|
||||
cmd!("table.insertColumnLeft", "Insert Left", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "colLeft"
|
||||
s,
|
||||
p,
|
||||
"colLeft",
|
||||
"table.insertColumnLeft"
|
||||
)),
|
||||
cmd!("table.insertColumnRight", "Insert Right", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "colRight"
|
||||
s,
|
||||
p,
|
||||
"colRight",
|
||||
"table.insertColumnRight"
|
||||
)),
|
||||
cmd!("table.deleteRow", "Delete Rows", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "delRow"
|
||||
s,
|
||||
p,
|
||||
"delRow",
|
||||
"table.deleteRow"
|
||||
)),
|
||||
cmd!("table.deleteColumn", "Delete Columns", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
|
||||
s, p, "delCol"
|
||||
s,
|
||||
p,
|
||||
"delCol",
|
||||
"table.deleteColumn"
|
||||
)),
|
||||
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
|
||||
cmd!(
|
||||
@@ -225,16 +246,26 @@ impl TableFrame {
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
/// A `[r, c]` cell param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid cell, so a bad cell never acts on the selection.
|
||||
fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>> {
|
||||
let v = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
match (idx(0), idx(1)) {
|
||||
(Some(r), Some(c)) => Ok(Some((r, c))),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The table's row/col to act on: params, the edited cell, or the selected cells.
|
||||
fn at(s: &Session, p: &Value) -> (usize, usize) {
|
||||
/// A present but invalid `row`/`col` is an error; only an absent one falls back.
|
||||
fn at(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let st = s.active();
|
||||
let cell = st.and_then(|d| d.selection.text.as_ref().and_then(|t| t.cell)).or_else(|| st.and_then(|d| d.selection.cells.map(|c| c.1)));
|
||||
(usize_param(p, "row").or(cell.map(|c| c.0)).unwrap_or(0), usize_param(p, "col").or(cell.map(|c| c.1)).unwrap_or(0))
|
||||
Ok((opt_usize_param(p, "row", cmd)?.or(cell.map(|c| c.0)).unwrap_or(0), opt_usize_param(p, "col", cmd)?.or(cell.map(|c| c.1)).unwrap_or(0)))
|
||||
}
|
||||
|
||||
fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result<Value> {
|
||||
@@ -282,10 +313,10 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let (r, c) = at(s, p);
|
||||
fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result<Value> {
|
||||
let (r, c) = at(s, p, name)?;
|
||||
let op = op.to_string();
|
||||
with_table(s, p, "table", move |t| {
|
||||
with_table(s, p, name, move |t| {
|
||||
match op.as_str() {
|
||||
"rowAbove" => t.insert_row(r),
|
||||
"rowBelow" => t.insert_row(r.saturating_add(1)),
|
||||
@@ -293,12 +324,12 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
"colRight" => t.insert_col(c.saturating_add(1)),
|
||||
"delRow" => {
|
||||
if !t.delete_row(r) {
|
||||
return Err(bad("table.deleteRow", "can't delete the last row"));
|
||||
return Err(bad(name, "can't delete the last row"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !t.delete_col(c) {
|
||||
return Err(bad("table.deleteColumn", "can't delete the last column"));
|
||||
return Err(bad(name, "can't delete the last column"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,8 +339,8 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel = s.doc()?.selection.cells;
|
||||
let from = cell_param(p, "from").or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
|
||||
let to = cell_param(p, "to").or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
|
||||
let from = cell_param(p, "from", "table.merge")?.or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
|
||||
let to = cell_param(p, "to", "table.merge")?.or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
|
||||
with_table(
|
||||
s,
|
||||
p,
|
||||
@@ -319,7 +350,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cell = cell_param(p, "cell").unwrap_or_else(|| at(s, p));
|
||||
let cell = match cell_param(p, "cell", "table.split")? {
|
||||
Some(c) => c,
|
||||
None => at(s, p, "table.split")?,
|
||||
};
|
||||
with_table(s, p, "table.split", move |t| if t.split(cell.0, cell.1) { Ok(()) } else { Err(bad("table.split", "that cell isn't merged")) })
|
||||
}
|
||||
|
||||
@@ -533,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| {
|
||||
@@ -562,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) {
|
||||
@@ -577,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];
|
||||
|
||||
@@ -9,9 +9,14 @@ use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind,
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{DocState, Result, Selection, Session, Target, TextSel};
|
||||
use super::{
|
||||
CommandSpec, NO_TEXT_POINT, SELECT_TEXT_OR_SHAPE, always, bad, bool_or, cmd, edit_shapes, has_doc, has_slide, has_text, id_param, ok, shape_of,
|
||||
str_param, xfrm_of,
|
||||
};
|
||||
use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo};
|
||||
|
||||
/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands
|
||||
/// with their parameter docs and enabled predicates.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used),
|
||||
@@ -66,6 +71,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
// An input method asks for text around the caret to be removed (macOS press-and-hold,
|
||||
// dictation, Wayland text-input). Internal: the keyboard and control protocol keep the
|
||||
// existing text.delete spelling. `bytes` says the counts are UTF-8 bytes (winit's
|
||||
// `Ime::DeleteSurrounding`), otherwise characters, both in the logical text
|
||||
// `ime_surrounding` publishes: a paragraph mark or soft line break is one `\n` byte, so a
|
||||
// range may cross paragraphs. The selection and pre-edit are kept; byte ranges round
|
||||
// inward to whole scalars. A deletion while composing stays in the composition's single
|
||||
// undo step.
|
||||
//
|
||||
// `undoable: false` is deliberate even though a non-composing deletion is undoable: the
|
||||
// handler pushes its own history entry from a snapshot taken before the composition is
|
||||
// replaced, so the generic post-run undo hook must not add a duplicate. `journal` stays
|
||||
// off for the same reason an input-method edit is not a replayable document command.
|
||||
CommandSpec {
|
||||
id: "text.deleteSurrounding",
|
||||
label: "Delete Surrounding Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{before, after, bytes?: bool}",
|
||||
enabled: has_text,
|
||||
run: delete_surrounding,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
@@ -111,6 +140,240 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
|
||||
Some(sh.text.get_or_insert_with(TextBody::default))
|
||||
}
|
||||
|
||||
/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query.
|
||||
///
|
||||
/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the
|
||||
/// engine answer without copying a whole body or document.
|
||||
pub const IME_SURROUNDING_MAX_BYTES: usize = 4000;
|
||||
|
||||
/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8
|
||||
/// byte offsets. The current input-method composition (pre-edit) is removed from both the text
|
||||
/// and the offsets.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
pub text: String,
|
||||
pub cursor: usize,
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// How a soft line break reads in the IME view: a newline, like a paragraph break.
|
||||
///
|
||||
/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would
|
||||
/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines
|
||||
/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change.
|
||||
fn ime_char(ch: char) -> char {
|
||||
if ch == '\u{b}' { '\n' } else { ch }
|
||||
}
|
||||
|
||||
/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`.
|
||||
fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
p.runs
|
||||
.iter()
|
||||
.flat_map(|run| run.content().chars())
|
||||
.enumerate()
|
||||
.filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))))
|
||||
}
|
||||
|
||||
/// The same characters, newest first, without walking the paragraph from its start.
|
||||
fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
let mut starts = Vec::with_capacity(p.runs.len());
|
||||
let mut at = 0usize;
|
||||
for r in &p.runs {
|
||||
let n = r.char_len();
|
||||
starts.push((at, n));
|
||||
at += n;
|
||||
}
|
||||
p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| {
|
||||
let content = r.content();
|
||||
content.chars().rev().enumerate().filter_map(move |(k, ch)| {
|
||||
let i = start + n - 1 - k;
|
||||
(!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The composition's `(paragraph, start, end)` character range, when it is inside the body.
|
||||
fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> {
|
||||
let c = c?;
|
||||
let para = body.paragraphs.get(c.start.0)?;
|
||||
let start = c.start.1.min(para.char_len());
|
||||
let end = start.saturating_add(c.len).min(para.char_len());
|
||||
(start < end).then_some((c.start.0, start, end))
|
||||
}
|
||||
|
||||
/// Return the composition character range only when its paragraph index equals `pi`.
|
||||
fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> {
|
||||
comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e))
|
||||
}
|
||||
|
||||
/// Clip a model position to the body. An empty body only has position `(0, 0)`.
|
||||
fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> {
|
||||
if body.paragraphs.is_empty() {
|
||||
return (pos == (0, 0)).then_some((0, 0));
|
||||
}
|
||||
body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len())))
|
||||
}
|
||||
|
||||
/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
|
||||
/// characters), stopping at the end of the body. Returns the position reached and the characters
|
||||
/// and bytes consumed.
|
||||
///
|
||||
/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks
|
||||
/// that paragraph's runs from their start: runs do not cache character lengths, and a position is
|
||||
/// a character index. A megabyte paragraph therefore costs time proportional to the caret's
|
||||
/// character offset, even though the copied surrounding text stays within `limit`.
|
||||
fn walk_forward(
|
||||
body: &TextBody,
|
||||
comp: Option<(usize, usize, usize)>,
|
||||
from: (usize, usize),
|
||||
limit: usize,
|
||||
bytes: bool,
|
||||
) -> ((usize, usize), usize, usize) {
|
||||
let (mut pi, mut at) = from;
|
||||
let (mut chars, mut used) = (0usize, 0usize);
|
||||
// `limit` comes from a (possibly hostile) request: every count is checked, never added
|
||||
// blindly, so an enormous limit cannot overflow.
|
||||
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
|
||||
while let Some(para) = body.paragraphs.get(pi) {
|
||||
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
|
||||
if i < at {
|
||||
continue;
|
||||
}
|
||||
let n = ch.len_utf8();
|
||||
if !fits(used, chars, n) {
|
||||
return ((pi, i), chars, used);
|
||||
}
|
||||
used += n;
|
||||
chars += 1;
|
||||
at = i + 1;
|
||||
}
|
||||
if pi + 1 >= body.paragraphs.len() {
|
||||
break;
|
||||
}
|
||||
// The paragraph separator between this paragraph and the next.
|
||||
if !fits(used, chars, 1) {
|
||||
return ((pi, at), chars, used);
|
||||
}
|
||||
used += 1;
|
||||
chars += 1;
|
||||
pi += 1;
|
||||
at = 0;
|
||||
}
|
||||
((pi, at), chars, used)
|
||||
}
|
||||
|
||||
/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
|
||||
/// characters). Returns the position reached and the characters and bytes consumed.
|
||||
fn walk_backward(
|
||||
body: &TextBody,
|
||||
comp: Option<(usize, usize, usize)>,
|
||||
from: (usize, usize),
|
||||
limit: usize,
|
||||
bytes: bool,
|
||||
) -> ((usize, usize), usize, usize) {
|
||||
let (mut pi, mut at) = from;
|
||||
let (mut chars, mut used) = (0usize, 0usize);
|
||||
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
|
||||
loop {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) };
|
||||
let mut boundary = at.min(para.char_len());
|
||||
for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) {
|
||||
if i >= boundary {
|
||||
continue;
|
||||
}
|
||||
let n = ch.len_utf8();
|
||||
if !fits(used, chars, n) {
|
||||
return ((pi, boundary), chars, used);
|
||||
}
|
||||
used += n;
|
||||
chars += 1;
|
||||
boundary = i;
|
||||
}
|
||||
// This paragraph is consumed down to its start.
|
||||
if pi == 0 {
|
||||
return ((0, 0), chars, used);
|
||||
}
|
||||
if !fits(used, chars, 1) {
|
||||
return ((pi, 0), chars, used);
|
||||
}
|
||||
used += 1;
|
||||
chars += 1;
|
||||
pi -= 1;
|
||||
at = usize::MAX; // all of the previous paragraph is before us
|
||||
}
|
||||
}
|
||||
|
||||
/// The committed logical text between two model positions (excluding `end`). `None` when it is
|
||||
/// longer than `cap` bytes: every caller only needs a bounded answer.
|
||||
fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option<String> {
|
||||
let (a, b) = if start <= end { (start, end) } else { (end, start) };
|
||||
let mut out = String::new();
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return Some(out) };
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { usize::MAX };
|
||||
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
|
||||
if i < from || i >= to {
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
if out.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if pi < b.0 {
|
||||
out.push('\n');
|
||||
if out.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Committed surrounding text for the text being edited.
|
||||
///
|
||||
/// Returns `None` when the body or selection is invalid, or when the selection alone is wider
|
||||
/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct
|
||||
/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection.
|
||||
///
|
||||
/// Only a bounded window is measured and copied: the query walks at most the budget out from the
|
||||
/// selection on each side and does not copy the rest of the body. Finding the selection's
|
||||
/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not
|
||||
/// bounded independently of the paragraph length.
|
||||
pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {
|
||||
let body = body_of(st, t)?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
let caret = model_pos(body, t.caret)?;
|
||||
let anchor = model_pos(body, t.anchor)?;
|
||||
let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) };
|
||||
let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?;
|
||||
let selection_len = selected.len();
|
||||
let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len);
|
||||
let (_, _, before_available) = walk_backward(body, comp, start, budget, true);
|
||||
let (_, _, after_available) = walk_forward(body, comp, end, budget, true);
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let (window_start, _, _) = walk_backward(body, comp, start, before, true);
|
||||
let (window_end, _, _) = walk_forward(body, comp, end, after, true);
|
||||
let before_text = logical_between(body, comp, window_start, start, budget)?;
|
||||
let after_text = logical_between(body, comp, end, window_end, budget)?;
|
||||
let before_len = before_text.len();
|
||||
let mut text = before_text;
|
||||
text.push_str(&selected);
|
||||
text.push_str(&after_text);
|
||||
// The budget arithmetic above already bounds this; refuse rather than publish more.
|
||||
if text.len() > IME_SURROUNDING_MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
// The caret and anchor are the selection's own endpoints, so their offsets inside the
|
||||
// window are the window's start plus either end of the selected text.
|
||||
let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) };
|
||||
Some(ImeSurrounding { text, cursor, anchor })
|
||||
}
|
||||
|
||||
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
|
||||
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
|
||||
@@ -932,7 +1195,9 @@ fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
// The text changed under the pre-edit: the app settled it, so the platform's copy
|
||||
// must be cancelled too.
|
||||
s.settle_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
@@ -1154,6 +1419,93 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A `before`/`after` count from a control or input-method request.
|
||||
///
|
||||
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
|
||||
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
|
||||
/// would delete different text than asked.
|
||||
fn count_param(p: &Value, name: &str) -> usize {
|
||||
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
}
|
||||
|
||||
/// Delete at most the requested character or byte counts around the selection or pre-edit,
|
||||
/// preserving selection direction and composition boundaries and recording one undo step
|
||||
/// outside composition.
|
||||
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = count_param(p, "before");
|
||||
let after = count_param(p, "after");
|
||||
let bytes = bool_or(p, "bytes", false);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
|
||||
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
|
||||
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
|
||||
let ordered = {
|
||||
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
if a <= b { (a, b) } else { (b, a) }
|
||||
};
|
||||
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
|
||||
// scalars fully inside the requested before/after ranges around it are removed.
|
||||
let (start, end) = match comp {
|
||||
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
|
||||
None => ordered,
|
||||
};
|
||||
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
|
||||
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
|
||||
let deleted = before_chars + after_chars;
|
||||
if deleted == 0 {
|
||||
return Ok(json!({"deleted": 0}));
|
||||
}
|
||||
let before_doc = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let composing = st.composition.is_some();
|
||||
let vertex_kind = s.tool.vertex_kind.clone();
|
||||
edit_text(s, |body, t| {
|
||||
// Delete the after-range first, so the positions of the earlier range stay valid; then
|
||||
// the text before the selection.
|
||||
if after_chars > 0 {
|
||||
ed::delete(body, end, to);
|
||||
}
|
||||
if before_chars > 0 {
|
||||
ed::delete(body, from, start);
|
||||
}
|
||||
t.anchor = remap_after_delete(t.anchor, start, from, before_chars);
|
||||
t.caret = remap_after_delete(t.caret, start, from, before_chars);
|
||||
t.line_end = None;
|
||||
})?;
|
||||
if composing {
|
||||
if let Some(c) = s.doc_mut()?.composition.as_mut() {
|
||||
c.start = if before_chars == 0 {
|
||||
start
|
||||
} else if from.0 == start.0 {
|
||||
(start.0, start.1 - before_chars)
|
||||
} else {
|
||||
from
|
||||
};
|
||||
}
|
||||
} else {
|
||||
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind });
|
||||
}
|
||||
Ok(json!({"deleted": deleted}))
|
||||
}
|
||||
|
||||
/// Map a text position across the deletion of `before_chars` characters that begins at `start`
|
||||
/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier
|
||||
/// paragraph those paragraphs merge, so later paragraph numbers shift too.
|
||||
fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) {
|
||||
if before_chars == 0 || pos < start {
|
||||
return pos;
|
||||
}
|
||||
if from.0 == start.0 {
|
||||
// Only endpoints in the start paragraph move; later paragraphs keep their text.
|
||||
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
|
||||
} else {
|
||||
(from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 })
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
@@ -1583,6 +1935,210 @@ mod rtl_promote_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_surrounding_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id.
|
||||
fn edit(s: &mut Session) -> u64 {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Test helper: return the identified shape's text, or an empty string when it is gone.
|
||||
fn text(s: &Session, id: u64) -> String {
|
||||
let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap());
|
||||
s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Test helper: set an in-paragraph text selection through the engine command path.
|
||||
fn select(s: &mut Session, anchor: usize, caret: usize) {
|
||||
s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap();
|
||||
}
|
||||
|
||||
/// DeleteSurrounding removes only the requested text around the selection. The selection
|
||||
/// itself and its direction stay, so the following commit replaces it.
|
||||
#[test]
|
||||
fn selected_text_survives_the_requested_characters_or_bytes_around_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀
|
||||
s.delete_surrounding(1, 1, false).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
|
||||
select(&mut s, 1, 3);
|
||||
s.delete_surrounding(1, 1, true).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
}
|
||||
|
||||
/// A byte range removes only whole scalars fully contained in it. A count that ends inside
|
||||
/// a character leaves that character whole (and undeleted), never splitting it.
|
||||
#[test]
|
||||
fn byte_counts_round_inward_to_whole_characters() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 2, 2); // after é: bytes 0..3
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar");
|
||||
s.delete_surrounding(2, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
|
||||
select(&mut s, 2, 2);
|
||||
s.delete_surrounding(0, 1, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar");
|
||||
s.delete_surrounding(0, 4, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji");
|
||||
}
|
||||
|
||||
/// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed
|
||||
/// selection, removes the model-stored pre-edit, and treats an empty body as available.
|
||||
#[test]
|
||||
fn surrounding_text_is_committed_logical_and_bounded() {
|
||||
use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding};
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8));
|
||||
|
||||
s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor");
|
||||
assert_eq!(q.text, "aé😀b");
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
s.compose_text("日本", None).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text");
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": ""})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available");
|
||||
|
||||
let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500);
|
||||
s.execute("text.set", &json!({"id": id, "text": long})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len());
|
||||
assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window");
|
||||
}
|
||||
|
||||
/// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may
|
||||
/// remove one and merge the paragraphs, exactly as the published offsets say.
|
||||
#[test]
|
||||
fn paragraph_marks_are_deletable_logical_text() {
|
||||
use super::ime_surrounding;
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap();
|
||||
// The caret at the start of the second paragraph: after "ab" and its paragraph mark.
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3));
|
||||
|
||||
// One byte before the caret is that paragraph mark.
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "ab\ncd");
|
||||
|
||||
// Characters count the same mark.
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
|
||||
s.delete_surrounding(1, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// And from the other side: one character after the end of the first paragraph.
|
||||
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap();
|
||||
s.delete_surrounding(0, 1, false).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd");
|
||||
}
|
||||
|
||||
/// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the
|
||||
/// platform must never see the model's `\u{b}`, which its own text filter would reject.
|
||||
#[test]
|
||||
fn soft_line_breaks_read_as_newlines() {
|
||||
use super::ime_surrounding;
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap();
|
||||
assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph");
|
||||
let st = s.doc().unwrap();
|
||||
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2));
|
||||
|
||||
// The break is one logical byte, so removing it removes one character.
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "ab");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "a\u{b}b");
|
||||
}
|
||||
|
||||
/// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a
|
||||
/// different number that would delete other text than the platform asked for.
|
||||
#[test]
|
||||
fn out_of_range_counts_clamp_instead_of_truncating() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 1, 1);
|
||||
s.delete_surrounding(usize::MAX, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic");
|
||||
let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX);
|
||||
assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected);
|
||||
assert!(expected > 0, "the count is clamped, not wrapped to nothing");
|
||||
assert_eq!(super::count_param(&json!({}), "before"), 0);
|
||||
}
|
||||
|
||||
/// A non-composing input-method deletion is exactly one undo step; the manual history entry
|
||||
/// must not be duplicated by the generic command wrapper.
|
||||
#[test]
|
||||
fn a_non_composing_ime_deletion_is_one_undo_step() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
select(&mut s, 3, 3);
|
||||
s.delete_surrounding(1, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "aéb");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replace_font_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, TextBody};
|
||||
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_slide, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Session {
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key_token(key);
|
||||
@@ -266,6 +266,16 @@ impl Session {
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Remove text around the composition caret or selection, as an input method asks
|
||||
/// (`ImeEvent::DeleteSurrounding`/`DeleteSurroundingBytes`). Counts are characters or UTF-8
|
||||
/// bytes of the logical text the surrounding query publishes, so a range may cross a
|
||||
/// paragraph mark (read as one `\n` byte) and whole scalars only are removed; out-of-range
|
||||
/// counts are clamped. A deletion while composing stays inside the composition's single undo
|
||||
/// step.
|
||||
pub fn delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Value> {
|
||||
self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,6 +502,8 @@ pub struct Session {
|
||||
/// Presentations a host read off the UI thread, waiting for `file.adopt` (by token).
|
||||
loaded: Vec<(u64, cmd::file::LoadedFile)>,
|
||||
next_loaded: u64,
|
||||
/// Compositions the application settled itself (see [`Session::composition_settles`]).
|
||||
composition_settles: u64,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -502,6 +513,8 @@ impl Default for Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Install the process panic hook and construct an empty session with default preferences,
|
||||
/// clipboard, tool state, and no active document.
|
||||
pub fn new() -> Self {
|
||||
guard::install_panic_hook();
|
||||
Session {
|
||||
@@ -524,6 +537,7 @@ impl Session {
|
||||
panics_caught: 0,
|
||||
loaded: vec![],
|
||||
next_loaded: 0,
|
||||
composition_settles: 0,
|
||||
}
|
||||
}
|
||||
/// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]);
|
||||
@@ -610,11 +624,13 @@ impl Session {
|
||||
self.guarded(id, |s| s.execute_unguarded(id, params))
|
||||
}
|
||||
|
||||
/// Resolve and run a command, settling composition first, checking eligibility, rolling back a
|
||||
/// failed outer command, and committing the resulting undo entry or journal record.
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
@@ -688,6 +704,9 @@ impl Session {
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
///
|
||||
/// This is also how the input method's own commit ends a composition: the platform has
|
||||
/// already finished that one, so it must not ask for an interrupt.
|
||||
pub fn end_composition(&mut self) {
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
@@ -697,6 +716,27 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle a composition because the application did something else (a click, a key, a
|
||||
/// command, a target change) rather than because the input method committed it.
|
||||
///
|
||||
/// The platform still holds its own copy of that composition, so a host must publish one
|
||||
/// `should_interrupt_composition` for each increment of [`Self::composition_settles`]: the
|
||||
/// backend then cancels it instead of committing its text into whatever is selected next.
|
||||
pub fn settle_composition(&mut self) {
|
||||
if self.active().is_some_and(|d| d.composition.is_some()) {
|
||||
self.composition_settles = self.composition_settles.wrapping_add(1);
|
||||
}
|
||||
self.end_composition();
|
||||
}
|
||||
|
||||
/// How many compositions the application settled itself (clicks, keys, other commands).
|
||||
///
|
||||
/// A host compares this with the value it last published; every increment is one platform
|
||||
/// composition that must be interrupted. The input method's own commits do not count.
|
||||
pub fn composition_settles(&self) -> u64 {
|
||||
self.composition_settles
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
@@ -712,7 +752,7 @@ impl Session {
|
||||
st.revision += 1;
|
||||
// A composition belongs to the text body it began in: leaving that body settles it.
|
||||
if st.composition.is_some() && owner(&st.selection) != before {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::collections::VecDeque;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
use crate::{EngineError, Session, command_specs, sample};
|
||||
|
||||
/// Commands that touch the file system, other windows or real time; the soak drives the rest.
|
||||
fn skipped(id: &str) -> bool {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+242
-2
@@ -1,6 +1,10 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::*;
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::{
|
||||
DocState, History, Presentation, Session, Target, TextSel, ToolKind, Value, cmd, command_specs, connect, find_command, model, sample, tools,
|
||||
};
|
||||
use deckcraft_model::ShapeId;
|
||||
|
||||
fn session() -> Session {
|
||||
Session::with_new()
|
||||
@@ -210,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();
|
||||
@@ -4088,6 +4163,37 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
|
||||
// A file-defined weight needs a real regular/bold pair, independent of missing Inter
|
||||
// or whichever system face happened to load first. Leave bold unset for the table style.
|
||||
let fonts = deckcraft_fonts::FontDb::global();
|
||||
let regular = fonts.face("BIZ UDPGothic", "Regular");
|
||||
let bold = fonts.face("BIZ UDPGothic", "Bold");
|
||||
let metric_pair = regular.family == "BIZ UDPGothic"
|
||||
&& bold.family == "BIZ UDPGothic"
|
||||
&& regular.style == "Regular"
|
||||
&& bold.style == "Bold"
|
||||
&& regular.weight < bold.weight;
|
||||
// Required native builds validate and embed both BIZ faces (crates/fonts/build.rs);
|
||||
// optional local builds may only have Regular, so the weight assertion is skipped there.
|
||||
assert!(
|
||||
metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(),
|
||||
"required native build is missing the BIZ UDPGothic Regular/Bold metric pair"
|
||||
);
|
||||
if metric_pair {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
let deckcraft_model::ShapeKind::Table(table) = &mut slide.shape_mut(id).unwrap().kind else { panic!("not a table") };
|
||||
for cell in &mut table.rows[0].cells {
|
||||
for paragraph in &mut cell.text.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("BIZ UDPGothic".into());
|
||||
run.props.bold = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("optional BIZ pair absent: skipping weight-metric assertion; edit/resize checks still run");
|
||||
}
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let built_in = caret_point(&s, 0, 8);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
@@ -4112,7 +4218,9 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let p = caret_point(&s, 0, 8);
|
||||
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
|
||||
if metric_pair {
|
||||
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
|
||||
}
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// A double-click selects the word drawn there.
|
||||
@@ -4438,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| {
|
||||
@@ -4846,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();
|
||||
@@ -5406,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ impl Session {
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
|
||||
@@ -15,4 +15,4 @@ harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Last-resort face only (Ubuntu Light); no UI code.
|
||||
epaint_default_fonts = "0.36"
|
||||
epaint_default_fonts = "0.37"
|
||||
|
||||
+466
-29
@@ -1,27 +1,61 @@
|
||||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/latin-manifest.txt` (presentation fonts) and `fonts/manifest.txt` are embedded as
|
||||
//! `CRAFT_FONTS`, Latin first; unset, `CRAFT_FONTS` is empty. Web (wasm32) builds embed only Inter
|
||||
//! Regular/Bold and BIZ UDPGothic Regular, to stay within the web size budget. It only reads the
|
||||
//! local checkout: no network.
|
||||
//! `CRAFT_FONTS`, Latin first. Native builds additionally read
|
||||
//! `fonts/simplified-chinese-manifest.txt`; it is optional for ordinary local builds but a required
|
||||
//! native input (`CRAFT_FONTS_REQUIRED`) fails without it. Unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only Inter Regular/Bold (or a supported Variable face) and BIZ UDPGothic
|
||||
//! Regular, to stay within the web size budget; native builds also carry BIZ UDPGothic Bold.
|
||||
//! Required builds verify the faces they promise survive exclusion (and, on wasm, the web filter),
|
||||
//! so a pin without the Latin manifest or the native bold face fails instead of silently dropping
|
||||
//! them. It only reads the local checkout: no network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design).
|
||||
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif", "Noto Sans CJK SC"];
|
||||
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design, and no Noto
|
||||
/// CJK rebrand of Source Han on any platform).
|
||||
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif"];
|
||||
|
||||
/// Reject source families prohibited by the asset policy, including Source Han and every
|
||||
/// Noto CJK/Serif/Mono CJK variant.
|
||||
fn is_excluded_family(family: &str) -> bool {
|
||||
EXCLUDED_FAMILIES.contains(&family)
|
||||
|| family.starts_with("Source Han Sans")
|
||||
|| family.starts_with("Source Han Serif")
|
||||
|| family.starts_with("Noto Sans CJK")
|
||||
|| family.starts_with("Noto Serif CJK")
|
||||
|| family.starts_with("Noto Sans Mono CJK")
|
||||
}
|
||||
|
||||
/// The generated initialisers plus the faces that were actually embedded after exclusion and web
|
||||
/// filtering. Validation must use the latter, not the raw manifest text.
|
||||
#[derive(Default)]
|
||||
struct FontInput {
|
||||
source: String,
|
||||
faces: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl FontInput {
|
||||
/// Concatenate another input's generated source and embedded-face list into this one.
|
||||
fn append(&mut self, other: Self) {
|
||||
self.source.push_str(&other.source);
|
||||
self.faces.extend(other.faces);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read font-input environment and target architecture, write `craft_fonts.rs`, report font-input
|
||||
/// failures as warnings unless required, and always fail when the generated file cannot be written.
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
match input_fonts(dir.as_deref(), wasm, required) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if required => println!("cargo::error={e}"),
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: {e}"),
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
@@ -31,30 +65,88 @@ fn main() {
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
|
||||
let latin = dir.join("fonts/latin-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", latin.display());
|
||||
if latin.exists() {
|
||||
out.push_str(&manifest(dir, &latin)?);
|
||||
}
|
||||
out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?);
|
||||
Ok(out)
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<String, String> {
|
||||
load_fonts(dir, wasm, required).map(|input| input.source)
|
||||
}
|
||||
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String, String> {
|
||||
/// Use no fonts when the directory is absent unless required; otherwise load the manifests.
|
||||
fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<FontInput, String> {
|
||||
match dir {
|
||||
Some(dir) => craft_fonts(dir, wasm, required),
|
||||
None if required => Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()),
|
||||
None => Ok(FontInput::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load optional Latin input, required main input, and native-only Chinese input, then validate
|
||||
/// the promised required faces after filtering.
|
||||
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<FontInput, String> {
|
||||
let mut input = FontInput::default();
|
||||
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
|
||||
// A malformed or incomplete optional manifest is skipped as a unit: the main Japanese/symbol/RTL
|
||||
// faces and the native Chinese addition stay usable, and required builds still fail validation.
|
||||
let latin = dir.join("fonts/latin-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", latin.display());
|
||||
let latin_exists = match latin.try_exists() {
|
||||
Ok(exists) => exists,
|
||||
Err(e) if required => return Err(format!("{}: {e}", latin.display())),
|
||||
Err(e) => {
|
||||
println!("cargo::warning=skipping optional Latin fonts: {}: {e}", latin.display());
|
||||
false
|
||||
}
|
||||
};
|
||||
if latin_exists {
|
||||
match manifest(dir, &latin, wasm) {
|
||||
Ok(entries) => input.append(entries),
|
||||
Err(e) if required => return Err(e),
|
||||
Err(e) => println!("cargo::warning=skipping optional Latin fonts: {e}"),
|
||||
}
|
||||
}
|
||||
input.append(manifest(dir, &dir.join("fonts/manifest.txt"), wasm)?);
|
||||
// Native consumers opt in; older craft-fonts checkouts need not carry this manifest.
|
||||
// An invalid optional addition must never discard the main Japanese/symbol/RTL faces.
|
||||
if !wasm {
|
||||
let chinese = dir.join("fonts/simplified-chinese-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", chinese.display());
|
||||
let exists = match chinese.try_exists() {
|
||||
Ok(exists) => exists,
|
||||
Err(e) if required => return Err(format!("{}: {e}", chinese.display())),
|
||||
Err(e) => {
|
||||
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display());
|
||||
false
|
||||
}
|
||||
};
|
||||
if exists {
|
||||
match manifest(dir, &chinese, false) {
|
||||
Ok(entries) => input.append(entries),
|
||||
Err(e) if required => return Err(e),
|
||||
Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"),
|
||||
}
|
||||
} else if required {
|
||||
// A required native font input that silently lacks the reviewed Chinese manifest
|
||||
// would make the Chinese UI/canvas test pass only by skipping.
|
||||
return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display()));
|
||||
}
|
||||
}
|
||||
if required {
|
||||
validate_required_faces(&input.faces, wasm)?;
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// Parse one manifest, reject malformed rows, exclude banned families, apply the wasm filter,
|
||||
/// canonicalise assets, and emit initialisers.
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Result<FontInput, String> {
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
let mut input = FontInput::default();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// DeckCraft's asset policy: no Adobe-authored typefaces, even openly licensed ones.
|
||||
if EXCLUDED_FAMILIES.contains(family) {
|
||||
if is_excluded_family(family) {
|
||||
continue;
|
||||
}
|
||||
if wasm && !matches!((*family, *style), ("BIZ UDPGothic", "Regular") | ("Inter", "Regular" | "Bold" | "Variable")) {
|
||||
@@ -63,12 +155,357 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String,
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
input.faces.push(((*family).to_owned(), (*style).to_owned()));
|
||||
let _ = writeln!(
|
||||
out,
|
||||
input.source,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
|
||||
scripts.join(", "),
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// Required builds must actually carry the faces the platform promises: Inter Regular and Bold
|
||||
/// (or one Variable face) and BIZ UDPGothic Regular everywhere, BIZ UDPGothic Bold natively (the
|
||||
/// weight-pair metric test relies on it), plus LXGW WenKai Lite Regular for native builds. This
|
||||
/// runs after exclusion and, on wasm, the web filter, so an absent manifest, empty/all-excluded
|
||||
/// rows, a wrong-family row or a partial catalog cannot satisfy the gate. Wasm deliberately
|
||||
/// embeds only BIZ Regular to stay within the web size budget.
|
||||
fn validate_required_faces(faces: &[(String, String)], wasm: bool) -> Result<(), String> {
|
||||
let has = |family: &str, style: &str| faces.iter().any(|(f, s)| f == family && s == style);
|
||||
let inter = has("Inter", "Variable") || (has("Inter", "Regular") && has("Inter", "Bold"));
|
||||
let biz_regular = has("BIZ UDPGothic", "Regular");
|
||||
let biz_bold = wasm || has("BIZ UDPGothic", "Bold");
|
||||
let chinese = wasm || has("LXGW WenKai Lite", "Regular");
|
||||
let mut missing = Vec::new();
|
||||
if !inter {
|
||||
missing.push("Inter Regular/Bold (or Inter Variable)");
|
||||
}
|
||||
if !biz_regular {
|
||||
missing.push("BIZ UDPGothic Regular");
|
||||
}
|
||||
if !biz_bold {
|
||||
missing.push("BIZ UDPGothic Bold");
|
||||
}
|
||||
if !chinese {
|
||||
missing.push("LXGW WenKai Lite Regular");
|
||||
}
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("CRAFT_FONTS_REQUIRED is set but the embedded font set after filtering lacks {}", missing.join(" and ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||
const CHINESE_ROW: &str = "LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
|
||||
struct InputFixture(PathBuf);
|
||||
|
||||
impl InputFixture {
|
||||
/// Test helper: create a unique temporary fixture with a main manifest and asset file.
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("deck-font-input-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
std::fs::create_dir(path.join("fonts")).unwrap();
|
||||
std::fs::write(path.join("fonts/fixture.ttf"), b"build-input path fixture").unwrap();
|
||||
std::fs::write(
|
||||
path.join("fonts/manifest.txt"),
|
||||
"BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
BIZ UDPGothic | Bold | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
)
|
||||
.unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
/// Test helper: replace the fixture's main manifest contents.
|
||||
fn main(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/manifest.txt"), text).unwrap();
|
||||
}
|
||||
|
||||
/// Test helper: write the fixture's optional Latin manifest.
|
||||
fn latin(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/latin-manifest.txt"), text).unwrap();
|
||||
}
|
||||
|
||||
/// Test helper: remove the optional Latin manifest.
|
||||
fn no_latin(&self) {
|
||||
let _ = std::fs::remove_file(self.0.join("fonts/latin-manifest.txt"));
|
||||
}
|
||||
|
||||
/// Test helper: install a valid Inter Regular/Bold Latin manifest.
|
||||
fn valid_latin(&self) {
|
||||
self.latin(
|
||||
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
);
|
||||
}
|
||||
|
||||
/// Test helper: write the optional native Chinese manifest with the supplied rows.
|
||||
fn optional(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputFixture {
|
||||
/// Test helper: best-effort removal of the temporary fixture directory.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A required build without a font directory fails; an optional build embeds no fonts.
|
||||
#[test]
|
||||
fn required_input_cannot_silently_disappear() {
|
||||
assert!(input_fonts(None, false, true).unwrap_err().contains("CRAFT_FONTS_DIR"));
|
||||
assert_eq!(input_fonts(None, false, false).unwrap(), "");
|
||||
}
|
||||
|
||||
/// A malformed optional Chinese manifest, or one naming a missing asset, leaves the main catalog
|
||||
/// unchanged in optional builds, while a required build fails.
|
||||
#[test]
|
||||
fn broken_optional_manifest_preserves_the_main_fonts() {
|
||||
let fixture = InputFixture::new();
|
||||
let main = input_fonts(Some(&fixture.0), false, false).unwrap();
|
||||
fixture.optional("malformed row");
|
||||
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
|
||||
fixture.optional("LXGW WenKai Lite | Regular | missing.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid");
|
||||
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
|
||||
}
|
||||
|
||||
/// Adding or breaking native Chinese input must not change the wasm font initialiser bytes.
|
||||
#[test]
|
||||
fn native_opt_in_does_not_change_wasm_embedded_bytes() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
let before = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert!(native.contains("LXGW WenKai Lite"));
|
||||
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
|
||||
fixture.optional("malformed row");
|
||||
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
|
||||
}
|
||||
|
||||
/// Required native builds fail without the Chinese manifest; optional native and wasm builds
|
||||
/// stay valid.
|
||||
#[test]
|
||||
fn required_native_input_requires_the_chinese_manifest() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).unwrap_err().contains("simplified-Chinese"));
|
||||
// Optional native builds and wasm keep working without it.
|
||||
assert!(input_fonts(Some(&fixture.0), false, false).is_ok());
|
||||
assert!(input_fonts(Some(&fixture.0), true, true).is_ok());
|
||||
}
|
||||
|
||||
/// Every prohibited Source Han/Noto CJK family is excluded while permitted CJK, Latin, and
|
||||
/// Hebrew faces survive.
|
||||
#[test]
|
||||
fn all_noto_cjk_families_are_excluded_from_every_platform() {
|
||||
for family in [
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans CJK TC",
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans CJK KR",
|
||||
"Noto Sans CJK HK",
|
||||
"Noto Serif CJK SC",
|
||||
"Noto Serif CJK JP",
|
||||
"Noto Sans Mono CJK SC",
|
||||
"Source Han Sans SC",
|
||||
"Source Han Serif JP",
|
||||
] {
|
||||
assert!(is_excluded_family(family), "{family} would be embedded");
|
||||
}
|
||||
for family in ["LXGW WenKai Lite", "Noto Sans Hebrew", "Inter", "Noto Sans Symbols 2"] {
|
||||
assert!(!is_excluded_family(family), "{family} is an allowed input");
|
||||
}
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
let mut rows = String::from(CHINESE_ROW);
|
||||
for family in ["Noto Sans CJK SC", "Noto Serif CJK JP", "Noto Sans Mono CJK KR"] {
|
||||
rows.push_str(&format!("{family} | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"));
|
||||
}
|
||||
fixture.optional(&rows);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert!(!native.contains("Noto Sans CJK") && !native.contains("Noto Serif CJK") && !native.contains("Noto Sans Mono CJK"));
|
||||
assert!(native.contains("BIZ UDPGothic"), "the allowed main manifest is retained");
|
||||
assert!(native.contains("LXGW WenKai Lite"), "the valid Chinese row is retained");
|
||||
}
|
||||
|
||||
/// A required native Chinese manifest must embed LXGW WenKai Lite Regular, not merely list a
|
||||
/// nonempty manifest.
|
||||
#[test]
|
||||
fn required_native_chinese_manifest_must_emit_the_reviewed_face() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
let excluded = "Noto Sans CJK SC | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Noto Serif CJK JP | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
let wrong_family = "LXGW WenKai | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
for rows in ["", excluded, wrong_family] {
|
||||
fixture.optional(rows);
|
||||
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
|
||||
assert!(e.contains("LXGW WenKai Lite Regular"), "rows={rows:?}: {e}");
|
||||
assert!(input_fonts(Some(&fixture.0), false, false).is_ok(), "optional builds still preserve the valid core");
|
||||
}
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert!(native.contains("CraftFont { family: \"LXGW WenKai Lite\", style: \"Regular\""), "{native}");
|
||||
assert!(native.contains("BIZ UDPGothic") && native.contains("Inter"), "the rest of the required catalog is retained");
|
||||
}
|
||||
|
||||
/// A broken optional Latin manifest is skipped as a unit, preserving main and valid Chinese
|
||||
/// faces; required builds still fail.
|
||||
#[test]
|
||||
fn optional_broken_latin_manifest_is_skipped_without_losing_core_or_chinese() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let missing_asset = "Inter | Regular | fonts/missing.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
for latin in [None, Some("malformed row"), Some(missing_asset)] {
|
||||
match latin {
|
||||
Some(text) => fixture.latin(text),
|
||||
None => fixture.no_latin(),
|
||||
}
|
||||
for wasm in [false, true] {
|
||||
let catalog = input_fonts(Some(&fixture.0), wasm, false).unwrap();
|
||||
assert!(catalog.contains("BIZ UDPGothic"), "wasm={wasm}: the main manifest is retained");
|
||||
assert!(!catalog.contains("Inter"), "wasm={wasm}: the broken Latin manifest is skipped as a unit");
|
||||
if !wasm {
|
||||
assert!(catalog.contains("LXGW WenKai Lite"), "the valid Chinese addition is retained");
|
||||
}
|
||||
}
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).is_err(), "required builds must not skip the missing required faces");
|
||||
}
|
||||
}
|
||||
|
||||
/// The reviewed Latin catalog keeps 43 permitted faces plus BIZ and LXGW natively, while wasm
|
||||
/// remains a three-face subset.
|
||||
#[test]
|
||||
fn full_prior_latin_catalog_keeps_permitted_native_families() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.latin(include_str!("tests/fixtures/latin-families.txt"));
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert_eq!(native.matches("CraftFont {").count(), 46, "43 permitted Latin faces plus BIZ Regular/Bold and LXGW");
|
||||
for family in [
|
||||
"Carlito",
|
||||
"Caladea",
|
||||
"Liberation Serif",
|
||||
"Montserrat",
|
||||
"Lato",
|
||||
"Open Sans",
|
||||
"Roboto",
|
||||
"Merriweather",
|
||||
"Playfair Display",
|
||||
"Poppins",
|
||||
"Nunito Sans",
|
||||
] {
|
||||
assert!(native.contains(family), "missing prior presentation family {family}");
|
||||
}
|
||||
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
assert_eq!(web.matches("CraftFont {").count(), 3, "Inter Regular/Bold and BIZ only");
|
||||
for catalog in [&native, &web] {
|
||||
assert!(!catalog.contains("Source Sans 3"));
|
||||
assert!(!catalog.contains("Source Serif 4"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Required builds without the Latin catalog fail because Inter Regular/Bold (or Variable) is
|
||||
/// mandatory.
|
||||
#[test]
|
||||
fn required_builds_need_the_latin_catalog_for_inter() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for wasm in [true, false] {
|
||||
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
|
||||
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
|
||||
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
|
||||
}
|
||||
}
|
||||
|
||||
/// A single Inter weight, non-Inter substitutes, or excluded faces cannot satisfy the required
|
||||
/// Inter contract.
|
||||
#[test]
|
||||
fn required_builds_reject_partial_or_unrelated_faces() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for latin in [
|
||||
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Inter | Italic | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Inter | SemiBold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Carlito | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Source Sans 3 | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Source Sans 3 | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
] {
|
||||
fixture.latin(latin);
|
||||
for wasm in [true, false] {
|
||||
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
|
||||
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
|
||||
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BIZ UDPGothic Regular is required on every platform; required native builds additionally
|
||||
/// need Bold.
|
||||
#[test]
|
||||
fn required_builds_need_biz_udpgothic_regular() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
fixture.main(
|
||||
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
);
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for wasm in [true, false] {
|
||||
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
|
||||
assert!(e.contains("BIZ UDPGothic Regular"), "wasm={wasm}: {e}");
|
||||
if !wasm {
|
||||
assert!(e.contains("BIZ UDPGothic Bold"), "native builds need the weight pair: {e}");
|
||||
}
|
||||
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
|
||||
}
|
||||
}
|
||||
|
||||
/// Native required builds need the real BIZ Bold face for weight metrics; wasm deliberately
|
||||
/// embeds only Regular to stay within its size budget. This pins the contract the engine's
|
||||
/// required-build metric pair assertion relies on.
|
||||
#[test]
|
||||
fn required_native_builds_need_biz_bold_but_wasm_does_not() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
fixture.main("BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
|
||||
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
|
||||
assert!(e.contains("BIZ UDPGothic Bold"), "required native build accepted a Regular-only catalog: {e}");
|
||||
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
assert_eq!(web.matches("CraftFont {").count(), 3, "wasm keeps Inter Regular/Bold and BIZ Regular only");
|
||||
}
|
||||
|
||||
/// Required checks accept either static Inter Regular+Bold or a single Variable face, together
|
||||
/// with BIZ.
|
||||
#[test]
|
||||
fn required_builds_accept_static_or_variable_inter_with_biz() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for wasm in [true, false] {
|
||||
let static_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
|
||||
assert!(static_catalog.contains("Inter") && static_catalog.contains("BIZ UDPGothic"));
|
||||
}
|
||||
fixture.latin("Inter | Variable | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
|
||||
for wasm in [true, false] {
|
||||
let variable_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
|
||||
assert!(variable_catalog.contains("Inter") && variable_catalog.contains("BIZ UDPGothic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,16 +409,38 @@ mod tests {
|
||||
assert!(feature("liga").is_some());
|
||||
}
|
||||
|
||||
/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces;
|
||||
/// required builds also assert their presence.
|
||||
#[test]
|
||||
fn craft_fonts_latin_when_present() {
|
||||
let required = option_env!("CRAFT_FONTS_REQUIRED").is_some();
|
||||
if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") {
|
||||
assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest");
|
||||
eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)");
|
||||
return;
|
||||
}
|
||||
let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style);
|
||||
let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable"));
|
||||
if required {
|
||||
assert!(
|
||||
variable.is_some() || (regular.is_some() && bold.is_some()),
|
||||
"CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)"
|
||||
);
|
||||
}
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face("Inter", "Regular").family, "Inter");
|
||||
assert_eq!(db.face("Aptos", "Regular").family, "Inter");
|
||||
if let (Some(regular), Some(bold)) = (regular, bold) {
|
||||
// The build input carries one entry per face; real static Bold must not be the Regular
|
||||
// file relabelled in the manifest.
|
||||
assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular");
|
||||
let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold"));
|
||||
assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular"));
|
||||
assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold"));
|
||||
assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face");
|
||||
assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight);
|
||||
}
|
||||
if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") {
|
||||
assert_eq!(db.face("Calibri", "Bold").family, "Carlito");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Exercise the build-input parser without changing process environment variables.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../build.rs"]
|
||||
mod build_script;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde.
|
||||
Inter | Regular | fonts/fixture.ttf | Latn
|
||||
Inter | Italic | fonts/fixture.ttf | Latn
|
||||
Inter | SemiBold | fonts/fixture.ttf | Latn
|
||||
Inter | Bold | fonts/fixture.ttf | Latn
|
||||
Carlito | Regular | fonts/fixture.ttf | Latn
|
||||
Carlito | Bold | fonts/fixture.ttf | Latn
|
||||
Carlito | Italic | fonts/fixture.ttf | Latn
|
||||
Carlito | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Caladea | Regular | fonts/fixture.ttf | Latn
|
||||
Caladea | Bold | fonts/fixture.ttf | Latn
|
||||
Caladea | Italic | fonts/fixture.ttf | Latn
|
||||
Caladea | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Regular | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Bold | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Regular | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Bold | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Regular | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Bold | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Regular | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Italic | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Semibold | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Bold | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Regular | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Italic | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Semibold | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Bold | fonts/fixture.ttf | Latn
|
||||
Montserrat | Variable | fonts/fixture.ttf | Latn
|
||||
Montserrat | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Lato | Regular | fonts/fixture.ttf | Latn
|
||||
Lato | Bold | fonts/fixture.ttf | Latn
|
||||
Lato | Italic | fonts/fixture.ttf | Latn
|
||||
Open Sans | Variable | fonts/fixture.ttf | Latn
|
||||
Open Sans | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Roboto | Variable | fonts/fixture.ttf | Latn
|
||||
Roboto | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Merriweather | Regular | fonts/fixture.ttf | Latn
|
||||
Merriweather | Bold | fonts/fixture.ttf | Latn
|
||||
Merriweather | Italic | fonts/fixture.ttf | Latn
|
||||
Playfair Display | Variable | fonts/fixture.ttf | Latn
|
||||
Playfair Display | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Poppins | Regular | fonts/fixture.ttf | Latn
|
||||
Poppins | Bold | fonts/fixture.ttf | Latn
|
||||
Poppins | Italic | fonts/fixture.ttf | Latn
|
||||
Nunito Sans | Variable | fonts/fixture.ttf | Latn
|
||||
Nunito Sans | Italic Variable | fonts/fixture.ttf | Latn
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{Budget, Presentation, load, load_checked, load_within, read_entry, read_entry_max, save};
|
||||
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Master, Shape, ShapeId};
|
||||
use deckcraft_model::{Master, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const PART: &str = "ppt/embeddings/owned.xlsx";
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
|
||||
//! construction, fitted to how the shapes look and to what their adjust values mean.
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
Corner, Dir, FillMode, Geometry, Handle, Pb, Point, Rect, Vec2, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple,
|
||||
stroke_only, vis,
|
||||
};
|
||||
|
||||
/// Builds the presets this module knows; `None` for any other name.
|
||||
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Band, BezPath, CATALOG, Guide, Point, Rect, WarpLine, adjust_count, adjust_defaults, band_of_line, handles, is_follow, is_supported, warp,
|
||||
};
|
||||
|
||||
const W: f64 = 420.0;
|
||||
const H: f64 = 200.0;
|
||||
|
||||
+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
|
||||
|
||||
+1566
-1566
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
ColorRef, Fill, LayoutType, Paragraph, PhType, Placeholder, Presentation, Run, RunProps, SchemeSlot, Shape, ShapeId, ShapeKind, ShapeStyle,
|
||||
Slide, Table, TextBody, Theme, Xfrm, defaults, ink_pressure_is_legacy, ink_sample_width, parent_of, resolve, sibling_list_mut, style, text,
|
||||
theme, walk,
|
||||
};
|
||||
use crate::resolve::Ctx;
|
||||
|
||||
#[test]
|
||||
@@ -294,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>> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets.
|
||||
|
||||
use super::*;
|
||||
use super::{Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
|
||||
|
||||
/// Write one page holding `segments` (lacing already computed by `lace`).
|
||||
fn page(out: &mut Vec<u8>, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[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)),
|
||||
|
||||
+69
-1
@@ -1,6 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_render::RenderOpts;
|
||||
use kurbo::Rect;
|
||||
|
||||
use super::{
|
||||
MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces,
|
||||
master_texts, notes_frames, notes_slide_box, paper_size, raster_scale,
|
||||
};
|
||||
use deckcraft_model::text::TextBody;
|
||||
|
||||
fn deck(n: usize) -> Presentation {
|
||||
@@ -130,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,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
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;
|
||||
use deckcraft_model::text::TextBody;
|
||||
@@ -2009,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
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports
|
||||
//! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`).
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
BezPath, Fill, Image, Line, MAX_DASHES, PathEl, Point, Rect, RenderOpts, ShapeKind, dash_with_caps, head_size, images, limit_dash_count, paint,
|
||||
render_slide, trim_ends,
|
||||
};
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::style::ColorRef;
|
||||
use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, Xfrm};
|
||||
use kurbo::Shape as _;
|
||||
|
||||
fn deck_with(shapes: Vec<Shape>) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
@@ -160,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;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Affinity, Align, Anchor, Arc, AutoFit, BodyProps, Bullet, Caps, Cell, Ctx, Dash, Deco, DecoLine, FontDb, FontFace, GlyphRun, LINE_PITCH,
|
||||
MoveMode, NoFields, Opts, ParaProps, Point, Pos, Rect, Rgba, SUB_SUPER, Shape, Spacing, Strike, TextBody, TextLayout, autonum,
|
||||
break_opportunities, caps_char, chinese_num, covers_symbol, is_symbol_font, is_upright, layout, layout_scaled, map_symbol_bullet, measure_str,
|
||||
resolve, shaping_features, symbol_char, symbol_font_char, symbol_font_draw, symbol_unicode, symbol_unicode_alts,
|
||||
};
|
||||
use deckcraft_model::Fill;
|
||||
use deckcraft_model::defaults;
|
||||
use deckcraft_model::text::{Paragraph, Run};
|
||||
|
||||
@@ -18,15 +18,19 @@ deckcraft-render = { workspace = true }
|
||||
deckcraft-anim = { workspace = true }
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
[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.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -153,7 +153,7 @@ pub enum ExportTask {
|
||||
pub struct Backstage {
|
||||
pub page: Page,
|
||||
/// The open presentations (count, active) when it opened: a change closes it.
|
||||
pub docs: (usize, Option<usize>),
|
||||
pub docs: (usize, Option<u64>),
|
||||
/// Keyboard focus in the side menu (an index into [`entries`], top then bottom).
|
||||
pub focus: Option<usize>,
|
||||
/// Search text on Home, New and Open.
|
||||
@@ -180,7 +180,9 @@ pub struct Backstage {
|
||||
}
|
||||
|
||||
impl Backstage {
|
||||
pub fn new(page: Page, docs: (usize, Option<usize>)) -> Backstage {
|
||||
/// Construct the Backstage view at a page with the current document identity, so a replaced deck
|
||||
/// can be detected.
|
||||
pub fn new(page: Page, docs: (usize, Option<u64>)) -> Backstage {
|
||||
Backstage {
|
||||
page,
|
||||
docs,
|
||||
@@ -337,7 +339,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
if has_doc {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Back"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back"));
|
||||
let hovered = resp.hovered();
|
||||
if hovered {
|
||||
ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover);
|
||||
@@ -437,11 +439,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
|
||||
// ---------------------------------------------------------------- side menu
|
||||
|
||||
/// Draw one clickable Backstage navigation row and return its interaction response.
|
||||
fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click());
|
||||
let selected = nav == Nav::Page(bs.page);
|
||||
let label = nav.label();
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
|
||||
let p = ui.painter();
|
||||
let row = rect.shrink2(vec2(6.0, 1.0));
|
||||
if resp.hovered() {
|
||||
@@ -495,7 +498,7 @@ fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: b
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let thumb_w = if big { 200.0 } else { 160.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
@@ -599,7 +602,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
let missing = !app.recent_exists(&path);
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, CornerRadius::same(3), t.hover);
|
||||
@@ -625,7 +628,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)),
|
||||
Sense::click(),
|
||||
);
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent"));
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent"));
|
||||
if x.hovered() {
|
||||
ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
@@ -643,7 +646,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card });
|
||||
p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside);
|
||||
@@ -658,7 +661,7 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::R
|
||||
fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(rect, CornerRadius::same(2), t.selected);
|
||||
@@ -936,13 +939,18 @@ fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
|
||||
ui.add_space(14.0);
|
||||
let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1);
|
||||
ui.horizontal(|ui| {
|
||||
egui::ComboBox::from_id_salt("backstage_save_type").width(340.0).selected_text(current).show_ui(ui, |ui| {
|
||||
for (id, label, _) in SAVE_TYPES {
|
||||
if ui.selectable_label(bs.file_type == id, label).clicked() {
|
||||
bs.file_type = id;
|
||||
egui::ComboBox::from_id_salt("backstage_save_type")
|
||||
.width(340.0)
|
||||
.selected_text(current)
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label, _) in SAVE_TYPES {
|
||||
if ui.selectable_label(bs.file_type == id, label).clicked() {
|
||||
bs.file_type = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
|
||||
let web = app.services.pick_save.is_none();
|
||||
let label = if web { "Download" } else { "Save" };
|
||||
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
|
||||
@@ -1057,7 +1065,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
PrintScope::Current => "Print Current Slide",
|
||||
PrintScope::Custom => "Custom Range",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| {
|
||||
let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| {
|
||||
for (s, label) in
|
||||
[(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")]
|
||||
{
|
||||
@@ -1067,6 +1075,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range"));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let l = ui.label("Slides:");
|
||||
@@ -1079,7 +1088,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1);
|
||||
egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| {
|
||||
let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| {
|
||||
for (id, label) in LAYOUTS {
|
||||
if ui.selectable_label(bs.print_layout == id, label).clicked() {
|
||||
bs.print_layout = id;
|
||||
@@ -1087,27 +1096,38 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout"));
|
||||
ui.add_space(4.0);
|
||||
// PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are
|
||||
// for notes pages and handouts.
|
||||
ui.add_enabled_ui(bs.print_layout != "slides", |ui| {
|
||||
let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1);
|
||||
egui::ComboBox::from_id_salt("backstage_print_paper").width(240.0).selected_text(paper).show_ui(ui, |ui| {
|
||||
for (id, label, _) in deckcraft_engine::cmd::file::PAPERS {
|
||||
if ui.selectable_label(bs.print_paper == id, label).clicked() {
|
||||
bs.print_paper = id;
|
||||
egui::ComboBox::from_id_salt("backstage_print_paper")
|
||||
.width(240.0)
|
||||
.selected_text(paper)
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label, _) in deckcraft_engine::cmd::file::PAPERS {
|
||||
if ui.selectable_label(bs.print_paper == id, label).clicked() {
|
||||
bs.print_paper = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size"));
|
||||
ui.add_space(4.0);
|
||||
let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" };
|
||||
egui::ComboBox::from_id_salt("backstage_print_orientation").width(240.0).selected_text(orient).show_ui(ui, |ui| {
|
||||
for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] {
|
||||
if ui.selectable_label(bs.print_landscape == land, label).clicked() {
|
||||
bs.print_landscape = land;
|
||||
egui::ComboBox::from_id_salt("backstage_print_orientation")
|
||||
.width(240.0)
|
||||
.selected_text(orient)
|
||||
.show_ui(ui, |ui| {
|
||||
for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] {
|
||||
if ui.selectable_label(bs.print_landscape == land, label).clicked() {
|
||||
bs.print_landscape = land;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation"));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() {
|
||||
@@ -1230,10 +1250,11 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw a labelled icon button and return whether it was clicked while enabled.
|
||||
fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
@@ -1280,7 +1301,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
ExportTask::Handouts => {
|
||||
ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text));
|
||||
bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]);
|
||||
egui::ComboBox::from_id_salt("backstage_handouts")
|
||||
let combo = egui::ComboBox::from_id_salt("backstage_handouts")
|
||||
.width(240.0)
|
||||
.selected_text(format!("{} slides per page", bs.handouts_per_page))
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -1290,6 +1311,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page"));
|
||||
ui.add_space(10.0);
|
||||
if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() {
|
||||
app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page}));
|
||||
@@ -1301,7 +1323,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") {
|
||||
let selected = bs.file_type == *id;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, *label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label));
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(rect, CornerRadius::same(2), t.selected);
|
||||
@@ -1351,13 +1373,18 @@ fn account(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.set_width(340.0);
|
||||
heading(ui, "Appearance");
|
||||
let dark = app.ui.brightness == theme::Brightness::Dark;
|
||||
egui::ComboBox::from_id_salt("backstage_appearance").width(260.0).selected_text(if dark { "Dark" } else { "Light" }).show_ui(ui, |ui| {
|
||||
for (on, label) in [(false, "Light"), (true, "Dark")] {
|
||||
if ui.selectable_label(dark == on, label).clicked() {
|
||||
let _ = app.run("view.dark", json!({"on": on}));
|
||||
egui::ComboBox::from_id_salt("backstage_appearance")
|
||||
.width(260.0)
|
||||
.selected_text(if dark { "Dark" } else { "Light" })
|
||||
.show_ui(ui, |ui| {
|
||||
for (on, label) in [(false, "Light"), (true, "Dark")] {
|
||||
if ui.selectable_label(dark == on, label).clicked() {
|
||||
let _ = app.run("view.dark", json!({"on": on}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance"));
|
||||
ui.add_space(16.0);
|
||||
heading(ui, "User Information");
|
||||
dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device.");
|
||||
|
||||
@@ -12,7 +12,7 @@ use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::backstage::{Backstage, NAV_W, Nav, Page, PrintScope, entries, format_bytes, print_page_size, print_params, print_slides, split_path};
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
@@ -456,6 +456,101 @@ fn the_start_screen_is_file_home() {
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
/// The start screen is not a hidden slide: typing, pasting and IME events must not create or
|
||||
/// edit a presentation while the File-tab navigation keys still work.
|
||||
#[test]
|
||||
fn start_screen_ignores_text_paste_and_ime_while_file_keys_navigate() {
|
||||
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
h.input_mut().events.extend([
|
||||
egui::Event::Text("x".into()),
|
||||
egui::Event::Paste("pasted".into()),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text: "あ".into(), active_range_chars: None }),
|
||||
egui::Event::Ime(egui::ImeEvent::Commit("あ".into())),
|
||||
egui::Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().is_none(), "no typing or IME event opened a presentation");
|
||||
assert!(h.state().session.documents().is_empty(), "the start screen has no slide to edit");
|
||||
assert!(h.state().status.is_none(), "typing, paste and IME were ignored, not run and refused");
|
||||
assert_eq!(page(&h), Some(Page::New), "the File-tab arrow still navigates");
|
||||
assert!(h.output().platform_output.ime.is_none(), "the start screen publishes no input-method descriptor");
|
||||
}
|
||||
|
||||
/// The start screen keeps the shortcuts that open something in the app: the command palette and
|
||||
/// Preferences. Both are in-app windows, so no native dialog is involved.
|
||||
#[test]
|
||||
fn start_screen_opens_the_palette_and_preferences() {
|
||||
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
|
||||
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::P);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().palette.is_some(), "Ctrl+Shift+P opens the command palette");
|
||||
assert!(h.state().session.active().is_none(), "without creating a presentation");
|
||||
|
||||
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Comma);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"), "Ctrl+, opens Preferences");
|
||||
assert!(h.state().session.active().is_none(), "without creating a presentation");
|
||||
}
|
||||
|
||||
/// Ctrl+O still asks the app's open dialog on the start screen, and Alt+F opens the File tab with
|
||||
/// the keyboard in its side menu. The open dialog is a mock service, not the host's picker.
|
||||
#[test]
|
||||
fn start_screen_opens_files_and_the_file_tab_from_the_keyboard() {
|
||||
let opens: Rc<RefCell<Vec<String>>> = Default::default();
|
||||
let log = opens.clone();
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(move |purpose: &str| {
|
||||
log.borrow_mut().push(purpose.to_string());
|
||||
None
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = harness(SlideApp::new(Session::new(), services));
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::O);
|
||||
h.run_steps(3);
|
||||
let asked = opens.borrow();
|
||||
assert_eq!(asked.len(), 1, "Ctrl+O asks the app's open dialog once");
|
||||
assert_eq!(asked[0], "open", "Ctrl+O reaches the app's open dialog");
|
||||
assert!(h.state().session.active().is_none(), "a cancelled open leaves the start screen");
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
|
||||
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
|
||||
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), None);
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::F);
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Home), "Alt+F opens the File tab on Home");
|
||||
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0), "with the keyboard in the side menu");
|
||||
}
|
||||
|
||||
/// Document commands stay off the empty start screen: no save picker, find palette, hyperlink
|
||||
/// dialog, close prompt, status error or page change.
|
||||
#[test]
|
||||
fn start_screen_ignores_document_shortcuts() {
|
||||
let saves: Rc<RefCell<Vec<String>>> = Default::default();
|
||||
let log = saves.clone();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(move |suggested: &str| {
|
||||
log.borrow_mut().push(suggested.to_string());
|
||||
None
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = harness(SlideApp::new(Session::new(), services));
|
||||
for (mods, key) in [(Modifiers::COMMAND, Key::S), (Modifiers::COMMAND, Key::K), (Modifiers::COMMAND, Key::F), (Modifiers::COMMAND, Key::W)] {
|
||||
h.key_press_modifiers(mods, key);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().is_none(), "{key:?} did not open a presentation");
|
||||
assert!(h.state().session.documents().is_empty(), "{key:?} created no document");
|
||||
assert_eq!(page(&h), Some(Page::Home), "{key:?} left the start screen in place");
|
||||
assert!(h.state().status.is_none(), "{key:?} was ignored rather than run and refused");
|
||||
assert!(h.state().dialog.is_none(), "{key:?} opened no document dialog");
|
||||
assert!(h.state().palette.is_none(), "{key:?} opened no find palette");
|
||||
}
|
||||
assert!(saves.borrow().is_empty(), "Ctrl+S did not ask for a file");
|
||||
}
|
||||
|
||||
/// #346: a file picked in Open that can't be read used to drop back to the start screen without
|
||||
/// a word. The reason now shows over the start screen.
|
||||
#[test]
|
||||
|
||||
@@ -41,6 +41,12 @@ fn mods(ui: &Ui) -> Mods {
|
||||
/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom
|
||||
/// and two-finger scrolling are left as they are.
|
||||
pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
if ctx.will_discard() {
|
||||
// Multi-pass layout runs this function again with the input already consumed. Acting on
|
||||
// the discarded pass would start the twist and the replay would immediately end it,
|
||||
// splitting one gesture into two undo steps.
|
||||
return;
|
||||
}
|
||||
let delta = ctx.input(|i| i.rotation_delta());
|
||||
let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 };
|
||||
let turning = delta.abs() > 1e-4;
|
||||
@@ -137,6 +143,8 @@ fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 {
|
||||
})
|
||||
}
|
||||
|
||||
/// Paint the editing canvas, including rulers, selection handles, overlays, and current
|
||||
/// zoom/scroll state.
|
||||
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
@@ -144,7 +152,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
// The leftover fraction of a wheel line belongs to one presentation. Drop it whenever another
|
||||
// one is drawn here, hovered or not, so switching away and back starts from zero.
|
||||
let carry_id = ui.id().with("wheel_carry");
|
||||
let carry_id = ui.scope_id().with("wheel_carry");
|
||||
let doc_uid = st.uid;
|
||||
ui.data_mut(|d| {
|
||||
if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) {
|
||||
@@ -160,7 +168,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
// Scroll when zoomed beyond the window.
|
||||
let scroll_id = ui.id().with("canvas_scroll");
|
||||
let scroll_id = ui.scope_id().with("canvas_scroll");
|
||||
let last_id = scroll_id.with("last");
|
||||
let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default());
|
||||
let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id));
|
||||
@@ -241,7 +249,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
} else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) {
|
||||
// Point-based trackpad gestures keep their cooldown. Raw input avoids reading
|
||||
// the smoothed tail of a discrete mouse notch as another page turn.
|
||||
let gate = ui.id().with("wheel_gate");
|
||||
let gate = ui.scope_id().with("wheel_gate");
|
||||
let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY));
|
||||
let now = ui.input(|i| i.time);
|
||||
if now - last > 0.35 {
|
||||
@@ -355,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);
|
||||
@@ -454,17 +462,18 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
||||
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
||||
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
||||
///
|
||||
/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed
|
||||
/// text, owner and cause once at the end of the pass, after every IME event and editor command
|
||||
/// has settled.
|
||||
fn ime_output(app: &mut SlideApp, ui: &Ui, xf: Xf) {
|
||||
let ctx = ui.ctx();
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = ime_area(app, xf) else { return };
|
||||
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
|
||||
let r = to_global * r;
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
|
||||
});
|
||||
app.ime_pending = Some(to_global * r);
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
@@ -483,7 +492,8 @@ fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gi
|
||||
}
|
||||
let b = crate::media::gif_button_rect(r);
|
||||
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
|
||||
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
|
||||
let resp = ui.interact(b, ui.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" }));
|
||||
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
|
||||
if resp.clicked() {
|
||||
toggle = Some(g.id);
|
||||
@@ -569,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;
|
||||
}
|
||||
@@ -649,8 +667,9 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect
|
||||
let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top);
|
||||
p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside);
|
||||
|
||||
let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag());
|
||||
let id = ui.id().with("ruler_drag");
|
||||
let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler"));
|
||||
let id = ui.scope_id().with("ruler_drag");
|
||||
let mut drag: Option<RulerDrag> = ui.data(|d| d.get_temp::<RulerDrag>(id));
|
||||
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
|
||||
if resp.drag_started()
|
||||
@@ -716,17 +735,17 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect
|
||||
}
|
||||
// The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click
|
||||
// on the ruler then sets that kind of stop.
|
||||
let kind_id = ui.id().with("ruler_tab_kind");
|
||||
let kind_id = ui.scope_id().with("ruler_tab_kind");
|
||||
let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0);
|
||||
let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0));
|
||||
let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click());
|
||||
let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click());
|
||||
const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")];
|
||||
if cr.clicked() {
|
||||
kind = (kind + 1) % KINDS.len();
|
||||
ui.data_mut(|d| d.insert_temp(kind_id, kind));
|
||||
}
|
||||
let (kind_align, kind_label) = KINDS[kind % KINDS.len()];
|
||||
cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label));
|
||||
cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label));
|
||||
let cr = cr.on_hover_text(format!("{kind_label} (click to change)"));
|
||||
p.rect_stroke(
|
||||
corner.shrink(2.0),
|
||||
@@ -1709,8 +1728,16 @@ mod tests {
|
||||
pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s)
|
||||
}
|
||||
|
||||
/// Test helper: inject a platform-command-modified line wheel event and run thirty harness
|
||||
/// steps.
|
||||
fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) {
|
||||
let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND };
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, lines),
|
||||
phase: TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
modifiers: Modifiers::COMMAND,
|
||||
};
|
||||
h.event(wheel);
|
||||
// egui spreads a wheel notch over several frames.
|
||||
h.run_steps(30);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ use deckcraft_engine::{Mods, PointerEvent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::capture::{MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MemoryPlan};
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
@@ -132,6 +133,7 @@ fn f(p: &Value, k: &str) -> Option<f32> {
|
||||
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
|
||||
}
|
||||
|
||||
/// Dispatch one decoded control request and return an outcome for its caller to reply with.
|
||||
pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
@@ -173,8 +175,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
&& !show.ended
|
||||
&& let Some(key) = key_from(k)
|
||||
{
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
app.queue_input(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
app.queue_input(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
return ok(json!({"show": true}));
|
||||
}
|
||||
wrap(app.session.key(k, mods_from(p)).map_err(|e| e.to_string()))
|
||||
@@ -197,7 +199,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
} else {
|
||||
return err("missing `preedit` or `commit`");
|
||||
};
|
||||
app.synthetic.push(egui::Event::Ime(ev));
|
||||
app.queue_input(egui::Event::Ime(ev));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.tool.select" => {
|
||||
@@ -213,7 +215,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"ui.move" | "ui.click" | "ui.drag" => {
|
||||
let (Some(x), Some(y)) = (f(p, "x"), f(p, "y")) else { return err("missing x/y") };
|
||||
let pos = egui::pos2(x, y);
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.queue_input(egui::Event::PointerMoved(pos));
|
||||
if req.method == "ui.click" {
|
||||
let button = match s("button") {
|
||||
Some("right" | "secondary") => egui::PointerButton::Secondary,
|
||||
@@ -221,13 +223,13 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
};
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
|
||||
for _ in 0..count {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
app.queue_input(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
app.queue_input(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
}
|
||||
} else if req.method == "ui.drag" {
|
||||
let (Some(tx), Some(ty)) = (f(p, "toX"), f(p, "toY")) else { return err("missing toX/toY") };
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 120);
|
||||
app.synthetic.push(egui::Event::PointerButton {
|
||||
app.queue_input(egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
@@ -235,9 +237,9 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
});
|
||||
for k in 1..=steps {
|
||||
let a = k as f32 / steps as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
|
||||
app.queue_input(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton {
|
||||
app.queue_input(egui::Event::PointerButton {
|
||||
pos: egui::pos2(tx, ty),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
@@ -245,7 +247,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
});
|
||||
}
|
||||
ctx.request_repaint();
|
||||
ok(json!({"queued": app.synthetic.len()}))
|
||||
ok(json!({"queued": app.synthetic_queued_len()}))
|
||||
}
|
||||
"ui.set" => {
|
||||
if let Some(t) = s("tab") {
|
||||
@@ -346,7 +348,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"show.end" => {
|
||||
if app.show.is_some() {
|
||||
app.synthetic.push(egui::Event::Key {
|
||||
app.queue_input(egui::Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
@@ -409,11 +411,38 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<deckcraft_engine::T
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode and write a screenshot, or return inline base64 data, while enforcing the capture
|
||||
/// budgets and reporting encoding and write errors.
|
||||
pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let inline = path.is_none();
|
||||
// The request was reserved from the same geometry; re-check the exact image before making
|
||||
// the RGBA/PNG/base64 copies so a mismatched or oversized completion cannot allocate past
|
||||
// the capture budget. A path reply deliberately never builds the base64 text.
|
||||
let Some(plan) = MemoryPlan::for_image(w, h, inline) else {
|
||||
return json!({"ok": false, "error": "the screenshot has no usable image size"});
|
||||
};
|
||||
if plan.color_image > MAX_IMAGE_BYTES {
|
||||
return json!({"ok": false, "error": "the screenshot would exceed 128 MiB; use ui.render"});
|
||||
}
|
||||
if plan.peak > MAX_TOTAL_BYTES {
|
||||
return json!({"ok": false, "error": "the screenshot would exceed the 256 MiB capture budget; use ui.render"});
|
||||
}
|
||||
if image.pixels.len().checked_mul(4) != Some(plan.color_image) {
|
||||
return json!({"ok": false, "error": "the screenshot pixel data does not match its size"});
|
||||
}
|
||||
let Some((width, height)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()) else {
|
||||
return json!({"ok": false, "error": "the screenshot has no usable image size"});
|
||||
};
|
||||
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
let img = deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba };
|
||||
let img = deckcraft_render::Image { width, height, pixels: rgba };
|
||||
let png = img.to_png();
|
||||
if png.is_empty() {
|
||||
return json!({"ok": false, "error": "the screenshot could not be encoded"});
|
||||
}
|
||||
if png.len() > plan.png {
|
||||
return json!({"ok": false, "error": "the screenshot PNG exceeded its reserved budget"});
|
||||
}
|
||||
let Some(path) = path else {
|
||||
return json!({"ok": true, "result": {"width": w, "height": h, "png": deckcraft_engine::cmd::base64_encode(&png)}});
|
||||
};
|
||||
@@ -432,6 +461,34 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A path reply writes the PNG and returns no base64 text; an inline reply returns PNG
|
||||
/// bytes base64-encoded once. Both go through the same checked memory plan.
|
||||
#[test]
|
||||
fn screenshot_reply_is_path_only_or_explicitly_inline() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());
|
||||
let written = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&written);
|
||||
app.services.write = Some(Box::new(move |_path, bytes| {
|
||||
*sink.lock().unwrap() = bytes.to_vec();
|
||||
Ok(())
|
||||
}));
|
||||
let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3));
|
||||
|
||||
let path_reply = save_screenshot(&mut app, &image, Some("/tmp/shot.png"));
|
||||
assert_eq!(path_reply["ok"], true, "{path_reply}");
|
||||
assert!(path_reply["result"].get("png").is_none(), "a path reply never builds base64");
|
||||
let png = written.lock().unwrap().clone();
|
||||
assert_eq!(png.get(..8), Some(&b"\x89PNG\r\n\x1a\n"[..]), "the writer gets a PNG");
|
||||
|
||||
let inline = save_screenshot(&mut app, &image, None);
|
||||
assert_eq!(inline["ok"], true, "{inline}");
|
||||
let text = inline["result"]["png"].as_str().expect("inline base64");
|
||||
let decoded = deckcraft_engine::cmd::base64_decode(text).expect("valid base64");
|
||||
assert_eq!(decoded, png, "the inline reply is the same PNG");
|
||||
}
|
||||
|
||||
/// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719).
|
||||
#[test]
|
||||
fn take_and_abandon_are_exclusive() {
|
||||
|
||||
@@ -212,7 +212,7 @@ impl Default for View {
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
@@ -222,8 +222,8 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Sort");
|
||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
let field = ui.label("Sort");
|
||||
let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, k.label().0).clicked() {
|
||||
v.key = k;
|
||||
@@ -231,6 +231,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = combo.response.labelled_by(field.id);
|
||||
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
|
||||
v.ascending = !v.ascending;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user