mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-04 01:19:40 -06:00
Compare commits
407
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5004d3b443 | ||
|
|
23ac4f7efd | ||
|
|
69c91bb265 | ||
|
|
a790fdbc20 | ||
|
|
9d59fa96d3 | ||
|
|
2dfb07f2c4 | ||
|
|
f5fbadde63 | ||
|
|
996bb42ff5 | ||
|
|
3c3d504eb1 | ||
|
|
f5511d93b6 | ||
|
|
c6b7de43bc | ||
|
|
65d5c28512 | ||
|
|
d2a8be197d | ||
|
|
872cdbf331 | ||
|
|
0233d29064 | ||
|
|
a72a5167d0 | ||
|
|
b7d57bacab | ||
|
|
42108d0ee7 | ||
|
|
0107186cee | ||
|
|
54e24ab868 | ||
|
|
19dceffd0b | ||
|
|
1eec8b266c | ||
|
|
32f069eb84 | ||
|
|
aa13eeb45e | ||
|
|
50e02490b3 | ||
|
|
907ebd2de0 | ||
|
|
ed77cc9eaa | ||
|
|
928aa9cc16 | ||
|
|
5cb154ce9d | ||
|
|
ef55104799 | ||
|
|
3f29dccc0a | ||
|
|
160c923caf | ||
|
|
9f01f2ebdb | ||
|
|
d365e0b9de | ||
|
|
1d181017f6 | ||
|
|
996ca4dd65 | ||
|
|
6c1170af62 | ||
|
|
ed62aafbe6 | ||
|
|
9f68e8e2cc | ||
|
|
a6e2b64875 | ||
|
|
c7b91ff2ec | ||
|
|
6546136a42 | ||
|
|
67c1d78948 | ||
|
|
4df4ea6c9f | ||
|
|
751ac26d28 | ||
|
|
82ec8137dc | ||
|
|
b61c5f11bd | ||
|
|
a0956fee13 | ||
|
|
bf60dbb773 | ||
|
|
328b4ed274 | ||
|
|
1e5b78de37 | ||
|
|
73bd231589 | ||
|
|
63531e2a6a | ||
|
|
6cdbee50a3 | ||
|
|
0c00f22403 | ||
|
|
3c8dc6bc98 | ||
|
|
6ab485a096 | ||
|
|
597b9488f5 | ||
|
|
4eb9098a7c | ||
|
|
df4bb8b84a | ||
|
|
f66bed88ac | ||
|
|
ee64e93c4a | ||
|
|
f8128f9107 | ||
|
|
97050a353b | ||
|
|
fb3b857f53 | ||
|
|
ba3b57c756 | ||
|
|
e4a0c2971b | ||
|
|
0f5364ee9f | ||
|
|
bb417bf98a | ||
|
|
d00ef08fae | ||
|
|
0d06481c34 | ||
|
|
44b133a3c9 | ||
|
|
b66ba2e10a | ||
|
|
8ec4f6cabb | ||
|
|
5a668bf02e | ||
|
|
3e178d8f5a | ||
|
|
58c24883c7 | ||
|
|
f68150ee10 | ||
|
|
3cfd4c5e6e | ||
|
|
a200b92258 | ||
|
|
f2803102c1 | ||
|
|
fd4d2a8f79 | ||
|
|
e90e7f6c03 | ||
|
|
9e59b5f223 | ||
|
|
5cb6ba8403 | ||
|
|
62ada3ab9a | ||
|
|
77cd617596 | ||
|
|
72ac32715f | ||
|
|
e0992c1325 | ||
|
|
4bed9b6c37 | ||
|
|
ea9dad8ef9 | ||
|
|
0d45a64db2 | ||
|
|
e832a373f5 | ||
|
|
6c63345615 | ||
|
|
1f2c75a288 | ||
|
|
fd4c533425 | ||
|
|
d4d99c7627 | ||
|
|
9fce04f0b4 | ||
|
|
ffd77c7db0 | ||
|
|
1f6587b60d | ||
|
|
d09f4c1284 | ||
|
|
37a6aed679 | ||
|
|
02ea528e94 | ||
|
|
bcc4fc97e6 | ||
|
|
f4fcac9f2d | ||
|
|
babc950f81 | ||
|
|
468394b3c8 | ||
|
|
5eb0b7d2c0 | ||
|
|
01220df579 | ||
|
|
b0b87c5cad | ||
|
|
befa4cd69f | ||
|
|
cc00a17dfe | ||
|
|
90058395b7 | ||
|
|
ee39ff6781 | ||
|
|
bbd579813f | ||
|
|
eaf8d1ce3f | ||
|
|
f2f0c4373d | ||
|
|
14cc73101b | ||
|
|
650c4f9497 | ||
|
|
9f952550b2 | ||
|
|
5fb694d991 | ||
|
|
7f742c9d4f | ||
|
|
b5033b6213 | ||
|
|
b6c1d87cf0 | ||
|
|
c37b150950 | ||
|
|
f3d8ba85a6 | ||
|
|
e690829543 | ||
|
|
c0468b59cd | ||
|
|
5d27461fa6 | ||
|
|
773133ec42 | ||
|
|
8ec4f29059 | ||
|
|
87a10fc8e2 | ||
|
|
3fb6c43136 | ||
|
|
82573331a8 | ||
|
|
d990678b32 | ||
|
|
cd19752bb5 | ||
|
|
2ed2ce18d8 | ||
|
|
c5ac1dd44b | ||
|
|
84ac51a439 | ||
|
|
c3e9bd07e5 | ||
|
|
a3fbf02952 | ||
|
|
92bf7fc67d | ||
|
|
0ed4b394ac | ||
|
|
e747bff425 | ||
|
|
f7a7fd7f5f | ||
|
|
800ffeaa59 | ||
|
|
d080a23dc0 | ||
|
|
243c1a269f | ||
|
|
06a1ca4672 | ||
|
|
40728b5362 | ||
|
|
d2504e5f8b | ||
|
|
d92874f541 | ||
|
|
6a34f13808 | ||
|
|
6058fc7882 | ||
|
|
04b0958bee | ||
|
|
acf054852c | ||
|
|
861879f8ee | ||
|
|
e626625d40 | ||
|
|
4a0f48a04c | ||
|
|
8f6e1b420a | ||
|
|
c0db6ebeba | ||
|
|
a667e13a03 | ||
|
|
3bf35c801f | ||
|
|
47eb6732fa | ||
|
|
8a2eccc863 | ||
|
|
fb05eaa30f | ||
|
|
231a52438c | ||
|
|
a00b7e8a51 | ||
|
|
9678ed7d7f | ||
|
|
1de7b44a8d | ||
|
|
5a235590dd | ||
|
|
a96dcdbe8f | ||
|
|
98138f563b | ||
|
|
0e3f4cd353 | ||
|
|
72c8621358 | ||
|
|
cccc9064ca | ||
|
|
1de0101ced | ||
|
|
917d4b96a9 | ||
|
|
f5c2aca644 | ||
|
|
4408ff6153 | ||
|
|
ad97533aec | ||
|
|
727b682d17 | ||
|
|
8770fb90c3 | ||
|
|
b1900e526d | ||
|
|
9a716be59e | ||
|
|
479c3cc5ea | ||
|
|
4ce2944630 | ||
|
|
255df314da | ||
|
|
b7482dd392 | ||
|
|
e83f964077 | ||
|
|
eaeb06c655 | ||
|
|
ede7a7ead7 | ||
|
|
68dda6f7d8 | ||
|
|
9aef8cc5ab | ||
|
|
6113f4e1a2 | ||
|
|
77e3a444b8 | ||
|
|
4c1d77ddce | ||
|
|
d453bee54f | ||
|
|
09bbfc7217 | ||
|
|
443f503407 | ||
|
|
e812da58df | ||
|
|
ee4bdbcd7d | ||
|
|
adaa4e6fa1 | ||
|
|
446bc528ad | ||
|
|
3c068df628 | ||
|
|
f8165751e5 | ||
|
|
7190c85860 | ||
|
|
ddd39249ba | ||
|
|
b4c1905d78 | ||
|
|
2c11865b2d | ||
|
|
b92a7ddbd5 | ||
|
|
0768e95591 | ||
|
|
82adf7326e | ||
|
|
ef6fd29928 | ||
|
|
a3449185ac | ||
|
|
c4221b1a7c | ||
|
|
46f23229ab | ||
|
|
ae08bbe62d | ||
|
|
7b46958fa2 | ||
|
|
c7b8a11895 | ||
|
|
81eff036d2 | ||
|
|
bcbdb76bfd | ||
|
|
3dc66ac1a5 | ||
|
|
1b5d860dfc | ||
|
|
3e8a9193bc | ||
|
|
cee1d015ec | ||
|
|
dc1fc8d83b | ||
|
|
c66fee15f2 | ||
|
|
b7bec00386 | ||
|
|
087c11bb42 | ||
|
|
4d2c1a3bac | ||
|
|
28756fc755 | ||
|
|
c3d1df6d28 | ||
|
|
2275e56319 | ||
|
|
c8d1a7c7df | ||
|
|
8713686274 | ||
|
|
e6f9e912f7 | ||
|
|
6208d3eaeb | ||
|
|
58abe9ddb7 | ||
|
|
34cfc5b20b | ||
|
|
e279461ca7 | ||
|
|
63934efc69 | ||
|
|
f61b7a34dd | ||
|
|
b8cfcf20a2 | ||
|
|
1841ab75da | ||
|
|
c1b0cf72df | ||
|
|
ba29b61f8c | ||
|
|
11b271b9ae | ||
|
|
352fec7725 | ||
|
|
359e46f042 | ||
|
|
ecad13d1cc | ||
|
|
9554cc7eb8 | ||
|
|
7554d647cd | ||
|
|
e713bccefd | ||
|
|
2153f8ac4d | ||
|
|
42b0e1de8f | ||
|
|
9749ec992f | ||
|
|
f08e5f8691 | ||
|
|
5b0871e5f3 | ||
|
|
0f5d01a461 | ||
|
|
234329a8d2 | ||
|
|
71c5da07eb | ||
|
|
a3d14bc8b0 | ||
|
|
2feb7dc2b8 | ||
|
|
5187e01856 | ||
|
|
6030fb4f8c | ||
|
|
b3c36e816a | ||
|
|
95a55b0ea7 | ||
|
|
5e85f73c5e | ||
|
|
5191af8ebb | ||
|
|
8d278d71cb | ||
|
|
9cb51393b3 | ||
|
|
2d48221edb | ||
|
|
b3ccea8577 | ||
|
|
e96ae0b6f5 | ||
|
|
6fa022d50f | ||
|
|
62b06a0a91 | ||
|
|
c389f62b33 | ||
|
|
6d02e90177 | ||
|
|
52d81a9468 | ||
|
|
81ab1bce0d | ||
|
|
3465ed9d47 | ||
|
|
f29b7e7eed | ||
|
|
8075b7ecd8 | ||
|
|
a37fa65852 | ||
|
|
21bbb4127d | ||
|
|
36eaa7e051 | ||
|
|
932ec7bef6 | ||
|
|
34af55bd6b | ||
|
|
90c8f65f5e | ||
|
|
b082f12f70 | ||
|
|
fe54c0eb3c | ||
|
|
3a4e30e10e | ||
|
|
505a6f28ac | ||
|
|
0da9cac9dc | ||
|
|
97db282daf | ||
|
|
99bb0f0a62 | ||
|
|
32ef926c46 | ||
|
|
4c1916466b | ||
|
|
afe4c324a7 | ||
|
|
079ea961dd | ||
|
|
f7b76e7894 | ||
|
|
35a9b812fd | ||
|
|
8333c371e1 | ||
|
|
1bdd63dabe | ||
|
|
6fbfea12a6 | ||
|
|
62e020c1c1 | ||
|
|
8cc25169ab | ||
|
|
9e5afc2c90 | ||
|
|
0e556d4e84 | ||
|
|
cfd30ce4b2 | ||
|
|
23c19d8e0c | ||
|
|
14eef3aceb | ||
|
|
33b6e11726 | ||
|
|
6d2fd18923 | ||
|
|
d8c1880331 | ||
|
|
8c2560f31c | ||
|
|
2ba45cdd4c | ||
|
|
d9f90c517d | ||
|
|
2ac978ce54 | ||
|
|
f893f6af7d | ||
|
|
6d699b484f | ||
|
|
22d9796786 | ||
|
|
83ad5d287c | ||
|
|
9388884d20 | ||
|
|
e0c64599cc | ||
|
|
b54edd61b0 | ||
|
|
d0f0d14c99 | ||
|
|
c2f3397483 | ||
|
|
d1487e6916 | ||
|
|
63b181170c | ||
|
|
352086304e | ||
|
|
6a9f18af92 | ||
|
|
fd102fb47b | ||
|
|
edbf542570 | ||
|
|
fca0492725 | ||
|
|
b57ab3fb35 | ||
|
|
e75a72c0cc | ||
|
|
cd5ff01810 | ||
|
|
9c24a63d1e | ||
|
|
5638b0ca09 | ||
|
|
8dfa6f1994 | ||
|
|
ddd7d210ef | ||
|
|
d8c88cea1d | ||
|
|
322be04e7b | ||
|
|
2cad063d1d | ||
|
|
3356bc071c | ||
|
|
0efd6f1b85 | ||
|
|
1ca71474a2 | ||
|
|
f58ec39968 | ||
|
|
d027f9d866 | ||
|
|
7c891de450 | ||
|
|
b5928a182e | ||
|
|
5896859440 | ||
|
|
d989389441 | ||
|
|
31efc67f73 | ||
|
|
8680166081 | ||
|
|
90ce7dcb9b | ||
|
|
9d32bc4da9 | ||
|
|
faf4c0ee52 | ||
|
|
4c83fb9abe | ||
|
|
6a1b8f1e39 | ||
|
|
1e842137b0 | ||
|
|
d6f532bb0d | ||
|
|
952e4db95f | ||
|
|
f4ca20659c | ||
|
|
b5257f9446 | ||
|
|
50b200be7c | ||
|
|
48535be970 | ||
|
|
7b424a463b | ||
|
|
964f4759c7 | ||
|
|
110d46daae | ||
|
|
b003dde823 | ||
|
|
feecee0a30 | ||
|
|
356195ba33 | ||
|
|
b1645b006e | ||
|
|
b9842d0b82 | ||
|
|
cd2e780432 | ||
|
|
20553865cc | ||
|
|
d8961ebc1e | ||
|
|
9ad147b310 | ||
|
|
6fa873dcfc | ||
|
|
65826295cc | ||
|
|
8c8ab9561b | ||
|
|
62ea604394 | ||
|
|
fa0a3e9dee | ||
|
|
1b2e5ea9e0 | ||
|
|
c5994e16db | ||
|
|
5a28760b1e | ||
|
|
8ada601630 | ||
|
|
b31a59ad89 | ||
|
|
039395ae4f | ||
|
|
ae30368106 | ||
|
|
0865152e86 | ||
|
|
1956b6b491 | ||
|
|
6396c480b2 | ||
|
|
aef364867d | ||
|
|
78c77ad450 | ||
|
|
939bd12832 | ||
|
|
71352733d4 | ||
|
|
1705712a41 | ||
|
|
48c5ff67bf | ||
|
|
78ab8f0626 | ||
|
|
811758f791 | ||
|
|
c3981daeb6 | ||
|
|
300872fb7d | ||
|
|
6655a1d68d |
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -24,8 +24,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# 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/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
|
||||
@@ -6,11 +6,6 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a
|
||||
session, follow its standards, and contribute reusable learnings back there. Never code: repos
|
||||
don't share code.
|
||||
|
||||
These rules bind every AI agent and every human contributor. They come before any task,
|
||||
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
|
||||
file wins.**
|
||||
@@ -69,7 +64,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed
|
||||
or `docs/brand/` has no row.
|
||||
- **`NOTICE`** keeps the notices the licences require.
|
||||
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
repo. DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
`fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
|
||||
`../craft-fonts`).
|
||||
|
||||
@@ -99,7 +94,6 @@ once and tell the user.
|
||||
## 3. Never crash
|
||||
|
||||
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.**
|
||||
Standard: craftrules `standards/never-crash.md`.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
|
||||
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
@@ -160,7 +154,7 @@ to `main`.
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
docs:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
@@ -184,11 +178,13 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) |
|
||||
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
|
||||
@@ -9,8 +9,7 @@ before modules or other crate items; crate-level `//!` documentation may precede
|
||||
|
||||
## Start every session
|
||||
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
|
||||
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
|
||||
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
|
||||
## Habits
|
||||
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
|
||||
@@ -19,3 +18,8 @@ before modules or other crate items; crate-level `//!` documentation may precede
|
||||
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
|
||||
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
|
||||
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+45
-44
@@ -222,7 +222,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -823,12 +823,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -851,7 +851,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -859,7 +859,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -868,7 +868,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -888,13 +888,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -983,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1005,7 +1006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1014,7 +1015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1023,14 +1024,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1040,14 +1041,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1073,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1085,7 +1086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1096,7 +1097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1104,7 +1105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1113,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1122,21 +1123,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1146,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1164,7 +1165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1173,7 +1174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1185,15 +1186,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1211,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1227,7 +1228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1246,7 +1247,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1260,7 +1261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1285,7 +1286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1294,7 +1295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1651,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2707,7 +2708,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3013,7 +3014,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3486,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4041,7 +4042,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4098,7 +4099,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4731,10 +4732,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5658,7 +5659,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6176,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+7
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,10 +59,11 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -108,7 +109,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -286,7 +286,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+56
-5
@@ -2,9 +2,22 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -43,7 +56,7 @@ Calibration is the same as for the full number (see [docs/target-app-parity.md](
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
@@ -116,11 +129,11 @@ ships 26.
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
image paste on Windows (#1414), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
|
||||
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
|
||||
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, EMF+ records,
|
||||
MicroType Express font subsets and SVG. 36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
@@ -137,7 +150,28 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
|
||||
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
|
||||
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
|
||||
| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
|
||||
| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
|
||||
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
|
||||
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
|
||||
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
|
||||
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
|
||||
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
|
||||
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
|
||||
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
|
||||
| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) |
|
||||
| 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) |
|
||||
| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide |
|
||||
| 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) |
|
||||
| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear |
|
||||
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
|
||||
| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits |
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
|
||||
@@ -148,6 +182,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
|
||||
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
|
||||
| 2026-10-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" |
|
||||
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
|
||||
@@ -161,6 +196,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
|
||||
| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
@@ -283,7 +319,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-10 | Progress docs restructured; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
@@ -294,7 +330,21 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
|
||||
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
|
||||
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
|
||||
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
|
||||
| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. |
|
||||
| 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged |
|
||||
| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
@@ -312,6 +362,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-26 | trivial | Progress log: spaces after symbol-font characters |
|
||||
| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
|
||||
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
@@ -384,5 +435,5 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -4,29 +4,24 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect() }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
/// Returns whether this argument is a root flag. Both spellings (`--flag DIR` and
|
||||
/// `--flag=DIR`) may be repeated.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -40,8 +35,7 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -51,17 +45,16 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
fn roots_are_repeated_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -71,8 +64,8 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
@@ -84,7 +77,7 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
@@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
|
||||
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
|
||||
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
|
||||
line endings and an optional UTF-8 BOM at the start of the file are accepted.
|
||||
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
|
||||
File errors name the path; line-reading, JSON and command errors also name the line number.
|
||||
|
||||
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
@@ -127,16 +136,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
|
||||
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
|
||||
app is not confined by them).",
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed. Repeat either
|
||||
flag to grant access to more folders; a root left out grants none of its access. They run the in-process
|
||||
server, so they conflict with --connect (a running app is not confined by them).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
@@ -180,7 +189,10 @@ A presentation is edited by running commands on it:
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
`deckcraft-cli help run` explains how to write a command.
|
||||
|
||||
If an output reader closes early, run, render, render-transition and convert continue; their exit
|
||||
status reflects the work, not output delivery. Stop the process to cancel them.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
+119
-22
@@ -7,23 +7,38 @@ mod automation_args;
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
STDOUT_CLOSED.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
#[cfg(unix)]
|
||||
let mut stdout = {
|
||||
use std::os::fd::AsFd as _;
|
||||
std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.write_all(format!("{args}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
@@ -146,9 +161,13 @@ fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f6
|
||||
f()?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
Ok(summarize_ms(ms))
|
||||
}
|
||||
|
||||
/// Summarize one population of measurements; each maximum must come from that population.
|
||||
fn summarize_ms(mut ms: Vec<f64>) -> (f64, f64) {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
|
||||
Ok((median, ms.last().copied().unwrap_or(0.0)))
|
||||
(ms.get(ms.len() / 2).copied().unwrap_or(0.0), ms.last().copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
|
||||
@@ -210,9 +229,13 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
];
|
||||
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
|
||||
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
|
||||
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
|
||||
let render_all = time_ms(runs, || {
|
||||
for i in 0..slides {
|
||||
deckcraft_engine::render::render_slide(&doc, i, &opts);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
steps.insert(1, ("render all slides (1×)", render_all));
|
||||
let peak = peak_rss_mib();
|
||||
if as_json {
|
||||
let v = json!({
|
||||
@@ -450,6 +473,69 @@ fn output_format(path: &str) -> Result<&'static str, String> {
|
||||
})
|
||||
}
|
||||
|
||||
enum CommandSource {
|
||||
Inline(String),
|
||||
File(String),
|
||||
}
|
||||
|
||||
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
|
||||
line.clear();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok(!line.is_empty());
|
||||
}
|
||||
let newline = available.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.unwrap_or(available.len());
|
||||
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
|
||||
})?;
|
||||
line.extend_from_slice(&available[..count]);
|
||||
reader.consume(count + usize::from(newline.is_some()));
|
||||
if newline.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same command syntax and execution path for --cmd and each command-file line.
|
||||
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
|
||||
let (id, params) = match command.split_once('=') {
|
||||
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (command, json!({})),
|
||||
};
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
|
||||
use std::io::BufReader;
|
||||
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut bytes = Vec::new();
|
||||
let mut line_number = 0usize;
|
||||
loop {
|
||||
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
|
||||
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
|
||||
break;
|
||||
}
|
||||
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
|
||||
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
|
||||
let command = line.trim();
|
||||
if command.is_empty() || command.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut sample = false;
|
||||
@@ -463,7 +549,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
|
||||
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
@@ -486,14 +573,10 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let mut s = Session::new();
|
||||
open(&mut s, input.as_deref(), sample)?;
|
||||
for c in cmds {
|
||||
let (id, p) = match c.split_once('=') {
|
||||
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (c.clone(), json!({})),
|
||||
};
|
||||
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
for source in cmds {
|
||||
match source {
|
||||
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
|
||||
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
|
||||
}
|
||||
}
|
||||
if let Some(p) = save {
|
||||
@@ -624,7 +707,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
let roots = AutomationRoots::new(&roots_args.read_roots, &roots_args.write_roots)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
@@ -641,8 +724,12 @@ so drop --connect (or use --connect without the roots)"
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
let show = |roots: Vec<String>| if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
|
||||
eprintln!(
|
||||
"deckcraft-cli: files confined: read roots {}, write roots {}",
|
||||
show(roots.read_roots().map(|r| r.display().to_string()).collect()),
|
||||
show(roots.write_roots().map(|r| r.display().to_string()).collect())
|
||||
);
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
@@ -663,6 +750,16 @@ mod tests {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_summary_keeps_median_and_max_from_one_population() {
|
||||
let slide_medians = [8.99, 16.565];
|
||||
let whole_deck = summarize_ms(vec![17.0, 18.0, 16.0]);
|
||||
assert_eq!(whole_deck, (17.0, 18.0));
|
||||
assert!(whole_deck.1 >= whole_deck.0);
|
||||
assert!(slide_medians.iter().sum::<f64>() > whole_deck.1, "fixture must expose summing independent slide medians");
|
||||
assert_eq!(summarize_ms(vec![]), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_prints_usage_and_writes_nothing() {
|
||||
assert!(render(&args(&["--help"])).is_ok());
|
||||
|
||||
@@ -20,6 +20,153 @@ fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn command_file_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn inspected_titles(out: &str) -> Vec<String> {
|
||||
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
|
||||
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
|
||||
let dir = command_file_dir("lines");
|
||||
let file = dir.join("commands with spaces.txt");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
|
||||
)
|
||||
.unwrap();
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
|
||||
let out = stdout(&o);
|
||||
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
|
||||
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 4);
|
||||
assert_eq!(titles[1], "Résumé = # 测试");
|
||||
assert_eq!(titles[3], "Last");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_files_and_inline_commands_keep_argument_order() {
|
||||
let dir = command_file_dir("mixed");
|
||||
let first = dir.join("first.txt");
|
||||
let second = dir.join("second.txt");
|
||||
let saved = dir.join("result.deckcraft");
|
||||
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
|
||||
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline first\"}",
|
||||
"--cmd-file",
|
||||
&first.to_string_lossy(),
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline middle\"}",
|
||||
"--cmd-file",
|
||||
&second.to_string_lossy(),
|
||||
"--cmd",
|
||||
"document.inspect",
|
||||
"--print",
|
||||
"--save",
|
||||
&saved.to_string_lossy(),
|
||||
]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
|
||||
assert_eq!(out.lines().count(), 5);
|
||||
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
|
||||
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
|
||||
let dir = command_file_dir("failure");
|
||||
let file = dir.join("commands.txt");
|
||||
let later = dir.join("later-in-file.deckcraft");
|
||||
let inline = dir.join("later-inline.deckcraft");
|
||||
let save = dir.join("saved.deckcraft");
|
||||
let export = dir.join("exported.png");
|
||||
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
|
||||
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
|
||||
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
|
||||
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
|
||||
std::fs::write(&save, b"keep saved file").unwrap();
|
||||
std::fs::write(&export, b"keep exported file").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--print",
|
||||
"--cmd-file",
|
||||
&file.to_string_lossy(),
|
||||
"--cmd",
|
||||
&inline_command,
|
||||
"--save",
|
||||
&save.to_string_lossy(),
|
||||
"--export",
|
||||
&export.to_string_lossy(),
|
||||
]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
|
||||
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
|
||||
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
|
||||
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_read_errors_name_the_path_and_utf8_line() {
|
||||
let dir = command_file_dir("read-errors");
|
||||
let missing = dir.join("missing.txt");
|
||||
let invalid = dir.join("invalid.txt");
|
||||
let save = dir.join("out.deckcraft");
|
||||
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
|
||||
for file in [&missing, &dir, &invalid] {
|
||||
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&file.display().to_string()), "{err}");
|
||||
if file == &invalid {
|
||||
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
|
||||
}
|
||||
assert!(!save.exists());
|
||||
}
|
||||
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_rejects_an_oversized_line_before_running_it() {
|
||||
let dir = command_file_dir("line-limit");
|
||||
let file = dir.join("commands.txt");
|
||||
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
|
||||
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
|
||||
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
|
||||
assert!(o.stdout.is_empty(), "the oversized command must not run");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_commands_keep_their_existing_syntax_and_errors() {
|
||||
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 3);
|
||||
assert_eq!(titles[2], "Inline = #");
|
||||
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
|
||||
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
@@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() {
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
|
||||
"commands execute in the order those options appear.",
|
||||
"If a command fails, run stops there and saves nothing.",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::fs::{self, File};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn closed_stdout() -> Stdio {
|
||||
let (writer, reader) = UnixStream::pair().unwrap();
|
||||
drop(reader);
|
||||
Stdio::from(OwnedFd::from(writer))
|
||||
}
|
||||
|
||||
fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_commands_or_save() {
|
||||
let dir = Scratch::new("save");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&[
|
||||
"run",
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"First"}"#,
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"Later"}"#,
|
||||
"--print",
|
||||
"--save",
|
||||
"out.deckcraft",
|
||||
],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let saved = dir.0.join("out.deckcraft");
|
||||
assert!(saved.is_file(), "requested save was skipped");
|
||||
let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap();
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
let slides = data["slides"].as_array().unwrap();
|
||||
assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_later_command_failure() {
|
||||
let dir = Scratch::new("command-failure");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(!out.status.success(), "a later command failure returned success");
|
||||
assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_save_failure() {
|
||||
let dir = Scratch::new("save-failure");
|
||||
fs::create_dir(dir.0.join("out.deckcraft")).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout());
|
||||
assert!(!out.status.success(), "a failed save returned success");
|
||||
assert!(!out.stderr.is_empty(), "missing save failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_rendered_slides() {
|
||||
let dir = Scratch::new("render-all");
|
||||
let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped());
|
||||
assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr));
|
||||
let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout());
|
||||
assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr));
|
||||
let names = |folder: &str| {
|
||||
let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let expected = names("normal");
|
||||
assert!(expected.len() > 1, "sample must have multiple slides");
|
||||
assert_eq!(names("closed"), expected, "rendering stopped after stdout closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_stdout_write_errors_fail() {
|
||||
let dir = Scratch::new("write-failure");
|
||||
let path = dir.0.join("readonly");
|
||||
fs::write(&path, b"").unwrap();
|
||||
let readonly = File::open(path).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly));
|
||||
assert!(!out.status.success(), "a non-broken stdout error returned success");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod cli;
|
||||
#[cfg(unix)]
|
||||
mod closed_stdout;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
@@ -87,6 +87,45 @@ fn mcp_automation_roots_confine_the_server() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_accepts_multiple_read_and_write_roots() {
|
||||
let base = tmpdir("multiple-roots");
|
||||
let read1 = base.join("read1");
|
||||
let read2 = base.join("read2");
|
||||
let write1 = base.join("write1");
|
||||
let write2 = base.join("write2");
|
||||
for dir in [&read1, &read2, &write1, &write2] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let (input1, input2) = (read1.join("one.txt"), read2.join("two.txt"));
|
||||
std::fs::write(&input1, "one").unwrap();
|
||||
std::fs::write(&input2, "two").unwrap();
|
||||
let (output1, output2) = (write1.join("one.deckcraft"), write2.join("two.deckcraft"));
|
||||
let args = [
|
||||
"--automation-read-root".to_owned(),
|
||||
read1.to_string_lossy().into_owned(),
|
||||
format!("--automation-read-root={}", read2.display()),
|
||||
"--automation-write-root".to_owned(),
|
||||
write1.to_string_lossy().into_owned(),
|
||||
format!("--automation-write-root={}", write2.display()),
|
||||
];
|
||||
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
("open_presentation", serde_json::json!({"path": input1})),
|
||||
("open_presentation", serde_json::json!({"path": input2})),
|
||||
("save_presentation", serde_json::json!({"path": output1})),
|
||||
("save_presentation", serde_json::json!({"path": output2})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(output1.exists());
|
||||
assert!(output2.exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
@@ -103,8 +142,12 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let mut full = vec!["mcp", flag, dir, flag];
|
||||
full.push(dir);
|
||||
let out = run(&full);
|
||||
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
|
||||
@@ -27,4 +27,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const PREFIX: &str = "#deckcraft-presenter=";
|
||||
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
fn origin() -> Result<String, String> {
|
||||
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||
}
|
||||
pub fn token() -> Option<String> {
|
||||
let hash = web_sys::window()?.location().hash().ok()?;
|
||||
let token = hash.strip_prefix(PREFIX)?;
|
||||
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||
}
|
||||
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||
}
|
||||
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err("Presenter message is too large".into());
|
||||
}
|
||||
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||
}
|
||||
struct State {
|
||||
popup: Option<web_sys::Window>,
|
||||
token: String,
|
||||
commands: VecDeque<Command>,
|
||||
ready: bool,
|
||||
refresh_images: bool,
|
||||
}
|
||||
pub struct BrowserHost {
|
||||
state: Rc<RefCell<State>>,
|
||||
origin: String,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||
images: DeltaCache,
|
||||
}
|
||||
impl BrowserHost {
|
||||
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let origin = origin()?;
|
||||
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||
let weak = Rc::downgrade(&state);
|
||||
let expected = origin.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||
let Some(popup) = state.popup.as_ref() else { return };
|
||||
if !same_source(&event, popup, &expected) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||
return;
|
||||
}
|
||||
match value.get("kind").and_then(|v| v.as_str()) {
|
||||
Some("ready") => {
|
||||
state.ready = true;
|
||||
state.refresh_images = true;
|
||||
}
|
||||
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
state.commands.push_back(command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||
}
|
||||
fn remove_button(&mut self) {
|
||||
if let Some((button, callback)) = self.button.take() {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
fn recovery_button(&mut self) {
|
||||
if self.button.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(body) = document.body() else { return };
|
||||
let Ok(element) = document.create_element("button") else { return };
|
||||
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||
button.set_text_content(Some("Open presenter window"));
|
||||
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||
let button_copy = button.clone();
|
||||
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||
if let Some(state) = weak.upgrade() {
|
||||
match open_popup(&state) {
|
||||
Ok(()) => {
|
||||
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||
}
|
||||
Err(_) => {
|
||||
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||
self.button = Some((button, callback));
|
||||
} else {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||
if let Some(popup) = &state.popup
|
||||
&& !popup.closed().unwrap_or(true)
|
||||
{
|
||||
let _ = popup.focus();
|
||||
return Ok(());
|
||||
}
|
||||
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||
url.set_hash(&format!("{PREFIX}{token}"));
|
||||
let popup = window
|
||||
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||
.map_err(error)?
|
||||
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||
if popup.closed().unwrap_or(true) {
|
||||
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||
}
|
||||
state.popup = Some(popup);
|
||||
state.token = token;
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
Ok(())
|
||||
}
|
||||
impl Host for BrowserHost {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.close();
|
||||
let result = open_popup(&self.state);
|
||||
if result.is_err() {
|
||||
self.recovery_button();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
self.state.borrow_mut().commands.drain(..).collect()
|
||||
}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
if !self.is_open() {
|
||||
self.recovery_button();
|
||||
return;
|
||||
}
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if !state.ready {
|
||||
return;
|
||||
}
|
||||
if let Some(popup) = state.popup.clone() {
|
||||
let token = state.token.clone();
|
||||
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||
send(
|
||||
&popup,
|
||||
&self.origin,
|
||||
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||
)
|
||||
});
|
||||
if sent.is_ok() {
|
||||
state.refresh_images = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(window) = state.popup.take() {
|
||||
let _ = window.close();
|
||||
}
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
state.token.clear();
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserHost {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup {
|
||||
token: String,
|
||||
origin: String,
|
||||
opener: web_sys::Window,
|
||||
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||
last_received: Rc<RefCell<f64>>,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
view: View,
|
||||
}
|
||||
impl Popup {
|
||||
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||
// exact source identity and origin before any data is accepted.
|
||||
let opener = window.opener().map_err(error)?;
|
||||
if opener.is_null() || opener.is_undefined() {
|
||||
return Err("The audience window is unavailable".into());
|
||||
}
|
||||
let opener: web_sys::Window = opener.unchecked_into();
|
||||
let origin = origin()?;
|
||||
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||
let received = last_received.clone();
|
||||
let target = snapshot.clone();
|
||||
let expected_origin = origin.clone();
|
||||
let expected_opener = opener.clone();
|
||||
let expected_token = token.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||
if let Some(previous) = target.borrow().as_ref() {
|
||||
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.current = previous.current.clone();
|
||||
}
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
if let Some(document) = window.document() {
|
||||
document.set_title("Presenter View — DeckCraft");
|
||||
}
|
||||
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||
Ok(popup)
|
||||
}
|
||||
}
|
||||
impl eframe::App for Popup {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.snapshot.borrow();
|
||||
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||
if !self.view.prepare(ui.ctx(), notes) {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if self.opener.closed().unwrap_or(true) {
|
||||
ui.heading("The audience window has closed.");
|
||||
return;
|
||||
}
|
||||
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||
ui.heading("The audience window is not responding.");
|
||||
ui.label("Return to the audience window to resume the connection.");
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
for command in self.view.ui(ui, snapshot) {
|
||||
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||
}
|
||||
} else {
|
||||
ui.label("Waiting for the audience window…");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ pub fn start() {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
if let Some(token) = crate::presenter::token() {
|
||||
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
host.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
|
||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||
Err(error) => {
|
||||
log::error!("Presenter window: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Services {
|
||||
presenter,
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -60,6 +60,8 @@ impl eframe::App for App {
|
||||
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
|
||||
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.0.cleanup_print_previews();
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +189,7 @@ 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 Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("DeckCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -68,3 +65,11 @@ sync_github_name = true
|
||||
|
||||
[people.SofiaAFigueredo]
|
||||
real_name = "Sofia Alves Figueredo"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
+59
-29
@@ -1,5 +1,7 @@
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
|
||||
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
||||
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
||||
/// once; top-level shapes only (groups move whole).
|
||||
///
|
||||
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
|
||||
/// pass: matching costs the sum of the two shape lists, not their product.
|
||||
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||
let mut used_a = vec![false; a.shapes.len()];
|
||||
let mut used_b = vec![false; b.shapes.len()];
|
||||
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
|
||||
&|x, y| x.name.starts_with("!!") && x.name == y.name,
|
||||
&|x, y| !x.name.is_empty() && x.name == y.name,
|
||||
&|x, y| {
|
||||
if x.kind_name() != y.kind_name() {
|
||||
return false;
|
||||
// Each shape's text, worked out once rather than inside every comparison.
|
||||
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
|
||||
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
|
||||
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
|
||||
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
|
||||
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
|
||||
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
|
||||
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
|
||||
_ => None,
|
||||
};
|
||||
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
|
||||
for key in passes {
|
||||
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
|
||||
// match, which is the one a search through `b` would find.
|
||||
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
|
||||
for (j, sb) in b.shapes.iter().enumerate() {
|
||||
if used_b.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let tx = text_of(x);
|
||||
!tx.trim().is_empty() && tx == text_of(y)
|
||||
},
|
||||
&|x, y| {
|
||||
x.kind_name() == y.kind_name()
|
||||
&& x.geom.preset_name().is_some()
|
||||
&& x.geom.preset_name() == y.geom.preset_name()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
|
||||
index.entry(k).or_default().push_back(j);
|
||||
}
|
||||
}
|
||||
if index.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, sa) in a.shapes.iter().enumerate() {
|
||||
if used_a.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
|
||||
if let Some((j, sb)) = found {
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
|
||||
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
|
||||
let Some(sb) = b.shapes.get(j) else { continue };
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// What one [`morph_pairs`] pass matches shapes on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum MorphKey {
|
||||
Name(String),
|
||||
Text(&'static str, String),
|
||||
Geom(&'static str, String),
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
let v = a + (b - a) * t;
|
||||
if v.is_finite() {
|
||||
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
f.shapes.push((sh, old));
|
||||
id
|
||||
};
|
||||
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
|
||||
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
|
||||
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
|
||||
for (ia, ib) in &pairs {
|
||||
partner.entry(*ib).or_insert(*ia);
|
||||
}
|
||||
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
|
||||
for x in &a.shapes {
|
||||
a_by_id.entry(x.id).or_insert(x);
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
if !paired_a.contains(&sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
for sb in &b.shapes {
|
||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
||||
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
|
||||
let Some(sa) = pair else {
|
||||
if !hidden_b(sb.id) {
|
||||
push(sb.clone(), false, s);
|
||||
|
||||
@@ -58,6 +58,9 @@ pub struct ShowState {
|
||||
pub playing: bool,
|
||||
/// On the end screen.
|
||||
pub ended: bool,
|
||||
/// The current slide was reached by going back (← / Backspace), so the show plays the
|
||||
/// transition of the slide it left, in reverse, as PowerPoint does (#1531).
|
||||
pub backward: bool,
|
||||
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
|
||||
active: bool,
|
||||
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
|
||||
@@ -208,6 +211,7 @@ impl ShowState {
|
||||
self.active = true;
|
||||
self.slide = i;
|
||||
self.ended = false;
|
||||
self.backward = false;
|
||||
// Time spent elsewhere never counts for this slide's triggered animations.
|
||||
self.last_now = None;
|
||||
let steps = ShowState::steps(pres, i);
|
||||
@@ -309,6 +313,7 @@ impl ShowState {
|
||||
}
|
||||
self.step = ShowState::steps(pres, self.slide);
|
||||
self.playing = false;
|
||||
self.backward = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
@@ -337,6 +342,7 @@ impl ShowState {
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
}
|
||||
self.backward = true;
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None => ShowAction::None,
|
||||
|
||||
@@ -763,6 +763,26 @@ fn show_skips_hidden_slides() {
|
||||
assert_eq!(s.goto(&p, 99), ShowAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_remembers_a_slide_was_reached_by_going_back() {
|
||||
// #1531: the UI plays the left slide's transition reversed only for Back.
|
||||
let p = pres(3);
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0));
|
||||
assert!(s.backward);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.goto(&p, 0), ShowAction::Slide(0));
|
||||
assert!(!s.backward, "a jump is not Back");
|
||||
// Back from the end screen shows the last slide again, without a transition to reverse.
|
||||
assert_eq!(s.goto(&p, 2), ShowAction::Slide(2));
|
||||
assert_eq!(s.next(&p), ShowAction::End);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(2));
|
||||
assert!(!s.backward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_loops() {
|
||||
let mut p = pres(3);
|
||||
@@ -1135,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
|
||||
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
|
||||
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
|
||||
// the shape of `b` left over.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_of_large_slides_stays_linear() {
|
||||
let n = 20_000u32;
|
||||
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
|
||||
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
|
||||
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
|
||||
// One named pair at opposite ends.
|
||||
a.shapes[0].name = "Logo".into();
|
||||
b.shapes[n as usize - 1].name = "Logo".into();
|
||||
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
|
||||
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
|
||||
assert_eq!(f.shapes.len(), 2 * n as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_states_of_a_large_build_stay_linear() {
|
||||
let n = 20_000usize;
|
||||
let mut s = slide_with(vec![]);
|
||||
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
|
||||
// One first-level paragraph owning every other paragraph.
|
||||
for p in body.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
}
|
||||
s.shapes[0].text = Some(body);
|
||||
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
s.animations = vec![a];
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
|
||||
for p in 0..n {
|
||||
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
|
||||
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
|
||||
}
|
||||
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
|
||||
}
|
||||
|
||||
+47
-12
@@ -103,6 +103,41 @@ pub struct Timeline {
|
||||
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
|
||||
/// (shape, member paragraph, owner paragraph).
|
||||
groups: Vec<(ShapeId, usize, usize)>,
|
||||
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
|
||||
/// rather than a scan of every effect, group and colour on the slide.
|
||||
index: TimelineIndex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TimelineIndex {
|
||||
/// Effects (indices into `effects`, in order) by target shape and paragraph.
|
||||
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
|
||||
/// Effects (indices into `effects`, in order) by target shape.
|
||||
by_shape: HashMap<ShapeId, Vec<usize>>,
|
||||
/// Owner paragraph of each member paragraph (the first group recorded wins).
|
||||
owner: HashMap<(ShapeId, usize), usize>,
|
||||
/// Groups (member, owner) by shape, in order.
|
||||
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
|
||||
/// Base colour by shape (the first recorded wins).
|
||||
color: HashMap<ShapeId, Rgba>,
|
||||
}
|
||||
|
||||
impl TimelineIndex {
|
||||
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
|
||||
let mut ix = TimelineIndex::default();
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
|
||||
ix.by_shape.entry(e.timing.shape).or_default().push(i);
|
||||
}
|
||||
for &(shape, member, owner) in groups {
|
||||
ix.owner.entry((shape, member)).or_insert(owner);
|
||||
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
|
||||
}
|
||||
for &(shape, c) in colors {
|
||||
ix.color.entry(shape).or_insert(c);
|
||||
}
|
||||
ix
|
||||
}
|
||||
}
|
||||
|
||||
enum Status {
|
||||
@@ -324,6 +359,7 @@ impl Timeline {
|
||||
};
|
||||
tl.colors.push((id, c));
|
||||
}
|
||||
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
|
||||
tl
|
||||
}
|
||||
|
||||
@@ -413,8 +449,12 @@ impl Timeline {
|
||||
|
||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
|
||||
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
|
||||
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
|
||||
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
|
||||
let owners: HashSet<usize> = v.iter().copied().collect();
|
||||
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
|
||||
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
|
||||
}
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
@@ -422,13 +462,8 @@ impl Timeline {
|
||||
|
||||
/// Shapes with any effect.
|
||||
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
||||
let mut v: Vec<ShapeId> = Vec::new();
|
||||
for e in &self.effects {
|
||||
if !v.contains(&e.timing.shape) {
|
||||
v.push(e.timing.shape);
|
||||
}
|
||||
}
|
||||
v
|
||||
let mut seen: HashSet<ShapeId> = HashSet::new();
|
||||
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
|
||||
}
|
||||
|
||||
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
||||
@@ -456,9 +491,9 @@ impl Timeline {
|
||||
triggers: &[TriggerPlay],
|
||||
stamps: &[StepStamp],
|
||||
) -> AnimState {
|
||||
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
|
||||
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
|
||||
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
|
||||
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let mut st = AnimState::default();
|
||||
// Initial visibility comes from the first effect in the Animation Pane order.
|
||||
let mut ordered: Vec<&Effect> = mine.collect();
|
||||
|
||||
@@ -425,6 +425,8 @@ impl Decoder {
|
||||
|
||||
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
|
||||
let mut tu = 0usize;
|
||||
// Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
|
||||
let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let obu = parse_obu_unit(data, pos)?;
|
||||
@@ -444,9 +446,10 @@ impl Decoder {
|
||||
}
|
||||
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
|
||||
if let Some(o) = self.frame_obu(&obu)? {
|
||||
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
|
||||
if existing >= 32 {
|
||||
return Err(Error::Invalid("too many frame outputs in chunk"));
|
||||
}
|
||||
existing += usize::from(matches!(o, Output::Existing { .. }));
|
||||
out.push((o, tu, spatial_id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,8 +515,18 @@ 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()
|
||||
/// Every name on the slide, group members included, and the number of shapes (which exceeds the
|
||||
/// number of names when two shapes share one).
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet<String>, usize) {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
if let Some(v) = current_shapes(doc, sel) {
|
||||
deckcraft_model::walk(v, &mut |sh, _| {
|
||||
names.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
}
|
||||
(names, count)
|
||||
}
|
||||
|
||||
/// The command palette runs a command with `{}`. True when the params doc names a value that
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Animation Styles",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations",
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Animation",
|
||||
["Animations", "Advanced Animation"],
|
||||
None,
|
||||
"{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}",
|
||||
"{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}",
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}",
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}",
|
||||
has_slide,
|
||||
options
|
||||
),
|
||||
@@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
|
||||
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
|
||||
}
|
||||
|
||||
fn build(p: &Value) -> Result<Animation> {
|
||||
fn build(p: &Value, slide: (f64, f64)) -> Result<Animation> {
|
||||
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
|
||||
let class = class_of(str_param(p, "class"));
|
||||
let info = match class {
|
||||
@@ -155,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
a.start = start_of(st);
|
||||
}
|
||||
if a.class == AnimClass::Path {
|
||||
a.path = Some(default_path(&a.effect, &a.option));
|
||||
a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option)));
|
||||
} else if p.get("path").is_some() {
|
||||
return Err(bad("animation", "`path` only fits a motion path (class `path`)"));
|
||||
}
|
||||
if a.effect == "spin" {
|
||||
a.amount = Some(360.0);
|
||||
@@ -166,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Most points of a motion path given as an array, and longest path string given as text.
|
||||
const MAX_PATH_POINTS: usize = 1_000;
|
||||
const MAX_PATH_TEXT: usize = 16 * 1024;
|
||||
/// Largest offset of a point, in slide points; anything beyond is not a place on any slide.
|
||||
const MAX_PATH_OFFSET: f64 = 1.0e6;
|
||||
|
||||
/// A number of a path string: slide fractions, rounded to 6 places.
|
||||
fn path_num(v: f64) -> String {
|
||||
let t = format!("{:.6}", v);
|
||||
let t = t.trim_end_matches('0').trim_end_matches('.');
|
||||
if t == "-0" || t.is_empty() { "0".into() } else { t.into() }
|
||||
}
|
||||
|
||||
/// Does a raw motion path string draw: only commands and numbers, every number finite, and at
|
||||
/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.)
|
||||
fn raw_path_draws(text: &str) -> bool {
|
||||
let cs: Vec<char> = text.chars().collect();
|
||||
let (mut i, mut nums) = (0, 0usize);
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') {
|
||||
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
|
||||
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
|
||||
if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default();
|
||||
match tok.parse::<f64>() {
|
||||
Ok(v) if v.is_finite() => nums += 1,
|
||||
_ => return false,
|
||||
}
|
||||
} else if " ,MmLlCcQqHhVvZzE".contains(c) {
|
||||
i += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
nums >= 4
|
||||
}
|
||||
|
||||
/// The motion path `path` asks for, as the OOXML path string animations store: either points
|
||||
/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?`
|
||||
/// to return to the first) or that string itself.
|
||||
fn path_param(p: &Value, slide: (f64, f64)) -> Result<Option<String>> {
|
||||
let Some(v) = p.get("path") else { return Ok(None) };
|
||||
let refuse = |why: &str| bad("animation", format!("`path` {why}"));
|
||||
match v {
|
||||
Value::String(text) => {
|
||||
let ok = text.len() <= MAX_PATH_TEXT
|
||||
&& text.contains(|c: char| c.is_ascii_digit())
|
||||
&& text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c))
|
||||
&& raw_path_draws(text);
|
||||
if !ok {
|
||||
return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)"));
|
||||
}
|
||||
Ok(Some(text.clone()))
|
||||
}
|
||||
Value::Array(points) => {
|
||||
if !(2..=MAX_PATH_POINTS).contains(&points.len()) {
|
||||
return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points")));
|
||||
}
|
||||
if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) {
|
||||
return Err(refuse("can't be placed: the slide has no size"));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (k, pt) in points.iter().enumerate() {
|
||||
let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else {
|
||||
return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points")));
|
||||
};
|
||||
out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1)));
|
||||
}
|
||||
if p.get("closed").and_then(Value::as_bool) == Some(true) {
|
||||
out.push_str("Z ");
|
||||
}
|
||||
out.push('E');
|
||||
Ok(Some(out))
|
||||
}
|
||||
_ => Err(refuse("must be [[x, y], …] or an OOXML path string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default motion paths, in slide-fraction units relative to the shape's position.
|
||||
pub fn default_path(effect: &str, option: &str) -> String {
|
||||
let (dx, dy) = match option {
|
||||
@@ -185,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
s.edit(|doc, sel| {
|
||||
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
|
||||
let sl = Arc::make_mut(sl);
|
||||
@@ -215,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The slide's width and height in points.
|
||||
fn slide_extent(s: &mut Session) -> Result<(f64, f64)> {
|
||||
let size = &s.doc()?.doc.slide_size;
|
||||
Ok((size.width, size.height))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
let path = a.path.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?);
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
@@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sl.animations.push(x);
|
||||
}
|
||||
Ok(json!({"count": sl.animations.len()}))
|
||||
Ok(match &path {
|
||||
Some(path) => json!({"count": sl.animations.len(), "path": path}),
|
||||
None => json!({"count": sl.animations.len()}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"byLetter" => TextBuild::ByLetter,
|
||||
_ => TextBuild::AsOne,
|
||||
});
|
||||
let path = path_param(p, slide_extent(s)?)?;
|
||||
each_target(s, p, |a| {
|
||||
if let (Some(path), AnimClass::Path) = (&path, a.class) {
|
||||
a.path = Some(path.clone());
|
||||
}
|
||||
if let Some(o) = &option {
|
||||
// Each target takes the option in its own effect's terms; one it has no such
|
||||
// option for keeps its own.
|
||||
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
|
||||
if let Some(o) = resolve_option(allowed, o) {
|
||||
if a.class == AnimClass::Path {
|
||||
if a.class == AnimClass::Path && path.is_none() {
|
||||
a.path = Some(default_path(&a.effect, &o));
|
||||
}
|
||||
a.option = o;
|
||||
@@ -339,7 +446,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide);
|
||||
let i = match opt_usize_param(p, "index", "animation.clear")? {
|
||||
Some(i) => i,
|
||||
None => s.doc()?.selection.slide,
|
||||
};
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).animations.clear();
|
||||
@@ -434,6 +544,19 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id = r.get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
|
||||
for bad_index in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err());
|
||||
let left = s.execute("animation.get", &json!({})).unwrap();
|
||||
assert_eq!(left.as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_painter_copies_effects_between_shapes() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -590,4 +713,76 @@ mod tests {
|
||||
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(s: &mut Session) -> Option<String> {
|
||||
let all = s.execute("animation.get", &json!({})).unwrap();
|
||||
all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from)
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// #1576: points in slide points, as offsets from the shape, become the slide-fraction path.
|
||||
#[test]
|
||||
fn a_custom_path_takes_its_points() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let w = s.doc().unwrap().doc.slide_size.width;
|
||||
let h = s.doc().unwrap().doc.slide_size.height;
|
||||
let r = s
|
||||
.execute(
|
||||
"animation.add",
|
||||
&json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E");
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E"));
|
||||
// closed shapes end back at the start; set replaces; no path keeps the default
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]}))
|
||||
.unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E"));
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_path_string_is_kept_and_options_edits_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E"));
|
||||
s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_path_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let many: Vec<Value> = (0..2_000).map(|i| json!([i, i])).collect();
|
||||
for bad_path in [
|
||||
json!([[0, 0]]),
|
||||
json!([]),
|
||||
json!([[0, 0], [1]]),
|
||||
json!([[0, 0], ["a", 1]]),
|
||||
json!([[0, 0], [1e300, 1]]),
|
||||
json!([[0, 0], [null, 1]]),
|
||||
json!("M 0 0 L <script> E"),
|
||||
json!("not a path"),
|
||||
json!("eeee1"),
|
||||
json!("M 0 0 L 1e999 0 E"),
|
||||
json!(5),
|
||||
Value::Array(many),
|
||||
Value::String("M 0 0 L 0.1 0.1 ".repeat(10_000)),
|
||||
] {
|
||||
let r = s.execute("animation.add", &json!({"effect": "customPath", "path": bad_path, "ids": [id]}));
|
||||
assert!(r.is_err(), "{bad_path:.60}");
|
||||
}
|
||||
assert!(
|
||||
s.execute("animation.add", &json!({"effect": "fade", "path": [[0, 0], [1, 1]], "ids": [id]})).is_err(),
|
||||
"path only fits a motion path"
|
||||
);
|
||||
assert!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().is_empty(), "a refused add left an animation behind");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// After an edit: refit every group whose children's geometry changed to the union of its
|
||||
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
|
||||
/// shrinks the group). The children keep their place on the slide: the group's box and its child
|
||||
/// space change together. `old` is the presentation before the edit; only slides the edit
|
||||
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
|
||||
/// that edit.
|
||||
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
|
||||
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
|
||||
// insertion or deletion shifts the indices, and shape ids repeat across slides.
|
||||
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
|
||||
for slide in new.slides.iter_mut() {
|
||||
let Some(before) = old_slides.get(&slide.id) else { continue };
|
||||
if std::sync::Arc::ptr_eq(before, slide) {
|
||||
continue;
|
||||
}
|
||||
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
|
||||
refit_list(Some(&before.shapes), shapes, 0);
|
||||
}
|
||||
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
|
||||
for master in new.masters.iter_mut() {
|
||||
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
|
||||
&& !std::sync::Arc::ptr_eq(before, master)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(master));
|
||||
}
|
||||
}
|
||||
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
|
||||
if let (Some(before), Some(after)) = (before, after)
|
||||
&& !std::sync::Arc::ptr_eq(before, after)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(after));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
|
||||
refit_list(Some(&old.shapes), &mut new.shapes, 0);
|
||||
for layout in new.layouts.iter_mut() {
|
||||
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
|
||||
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
|
||||
// is bounded like the model's other walks.
|
||||
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
|
||||
return;
|
||||
}
|
||||
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
|
||||
for sh in list.iter_mut() {
|
||||
let before = by_id.get(&sh.id).copied();
|
||||
let old_children = before.and_then(|b| match &b.kind {
|
||||
ShapeKind::Group { children, .. } => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
let own_before = before.map(|b| {
|
||||
(
|
||||
b.xfrm,
|
||||
match &b.kind {
|
||||
ShapeKind::Group { child, .. } => Some(*child),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
});
|
||||
let smart = sh.diagram.is_some();
|
||||
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
|
||||
refit_list(old_children, children, depth + 1);
|
||||
if smart {
|
||||
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
|
||||
continue;
|
||||
}
|
||||
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
|
||||
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = sh.xfrm else { continue };
|
||||
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
|
||||
let Some(u) = union_bounds(&xs) else { continue };
|
||||
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
|
||||
let placed = child_to_parent(&group, child, &union);
|
||||
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
|
||||
*child = union;
|
||||
}
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
|
||||
|
||||
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
|
||||
Some(Packed { bytes, content_type, crop: new_crop })
|
||||
}
|
||||
|
||||
/// Picture shapes in `shapes` (groups included) that `want` accepts.
|
||||
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
|
||||
/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
|
||||
fn pictures_mut<'a>(
|
||||
shapes: &'a mut [Shape],
|
||||
want: &dyn Fn(ShapeId) -> bool,
|
||||
out: &mut Vec<(&'a mut Shape, f64, f64)>,
|
||||
to_slide: deckcraft_geom::Affine,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
out.push(sh);
|
||||
} else if let Some(ch) = sh.children_mut() {
|
||||
pictures_mut(ch, want, out, depth + 1);
|
||||
let Some(xfrm) = sh.xfrm else { continue };
|
||||
let local_to_slide = to_slide * xfrm.affine();
|
||||
if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
let origin = local_to_slide * deckcraft_geom::Point::ZERO;
|
||||
let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
|
||||
let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
|
||||
out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
|
||||
continue;
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &mut sh.kind {
|
||||
let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
|
||||
pictures_mut(children, want, out, child_to_slide, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(slide) = doc.slides.get(i).cloned() else { continue };
|
||||
let mut slide = slide.as_ref().clone();
|
||||
let mut pics = vec![];
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
|
||||
let mut touched = false;
|
||||
for sh in pics {
|
||||
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
|
||||
for (sh, w, h) in pics {
|
||||
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
|
||||
let PictureFill { media, crop, .. } = fill.clone();
|
||||
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
|
||||
@@ -232,6 +246,114 @@ mod tests {
|
||||
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_pictures_use_their_displayed_size() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(600, 600);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
|
||||
let picture = Shape {
|
||||
id: ShapeId(70_101),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
let nested = Shape {
|
||||
id: ShapeId(70_102),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(70_103),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
|
||||
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
|
||||
assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
|
||||
assert!(
|
||||
s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
|
||||
"the under-target original media must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(2000, 1000);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
|
||||
let picture = Shape {
|
||||
id: ShapeId(70_111),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
let nested = Shape {
|
||||
id: ShapeId(70_112),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(70_113),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
|
||||
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
|
||||
assert_eq!(result["pictures"], 1);
|
||||
let slide = s.doc().unwrap().doc.slides.first().unwrap();
|
||||
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
|
||||
let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
|
||||
let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
|
||||
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
|
||||
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
|
||||
let image = image::load_from_memory(&media.data).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (960, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(800, 800);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(Shape {
|
||||
id: ShapeId(70_104),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
|
||||
assert_eq!(result["pictures"], 1);
|
||||
let slide = s.doc().unwrap().doc.slides.first().unwrap();
|
||||
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
|
||||
let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
|
||||
assert_eq!(fill.crop, [0.0; 4]);
|
||||
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
|
||||
let image = image::load_from_memory(&media.data).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (96, 192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_deletes_cropped_areas_or_keeps_them() {
|
||||
let src = png(400, 400);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use super::{
|
||||
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
|
||||
// that layout. With neither key, follow the current slide's layout.
|
||||
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
|
||||
None
|
||||
} else {
|
||||
usize_param(p, "layout")
|
||||
opt_usize_param(p, "layout", "view.slideMaster")?
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
|
||||
};
|
||||
let target = match layout {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||
f64_param, ok, usize_param, with_param,
|
||||
f64_param, ok, opt_usize_param, usize_param, with_param,
|
||||
};
|
||||
|
||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
// Check the index before anything is edited: a picture is added to the media first.
|
||||
let index = opt_usize_param(p, "index", "design.background")?;
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
let index = usize_param(p, "index");
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.slides[0].background.clone();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ 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);
|
||||
let (mut taken, mut count) = super::current_names(doc, sel);
|
||||
for sh in &mut shapes {
|
||||
// Only a name already on the slide is changed (two shapes with one name are what the
|
||||
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
|
||||
@@ -330,9 +330,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& !n.is_empty()
|
||||
&& n.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
sh.name = super::insert::first_free_name(base, &taken);
|
||||
sh.name = super::insert::first_free_name(base, &taken, count);
|
||||
}
|
||||
taken.insert(sh.name.clone());
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |m, _| {
|
||||
taken.insert(m.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
@@ -508,6 +511,19 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
paste_plain(s, &t)
|
||||
}
|
||||
|
||||
/// Duplicate gives a copy the next automatic name ("Rectangle 4" after "Rectangle 1"), as
|
||||
/// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing
|
||||
/// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}"
|
||||
/// counts as automatic.
|
||||
fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet<String>, count: usize) {
|
||||
let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) };
|
||||
let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' '));
|
||||
if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) {
|
||||
sh.name = super::insert::first_free_name(&base, taken, count);
|
||||
taken.insert(sh.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
@@ -532,6 +548,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
// Every name on the slide, group members included, so a copy never repeats one.
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
deckcraft_model::walk(&list, &mut |sh, _| {
|
||||
taken.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
walk_mut(&mut copies, &mut |c| {
|
||||
rename_automatic(c, &mut taken, count);
|
||||
count += 1;
|
||||
});
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
@@ -540,9 +567,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
}
|
||||
if !c.name.is_empty() {
|
||||
c.name = format!("{} copy", c.name.trim_end_matches(" copy"));
|
||||
}
|
||||
}
|
||||
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, opt_usize_param, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -345,6 +345,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
"png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -359,7 +360,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
"handouts" => {
|
||||
let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
|
||||
if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
|
||||
return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
|
||||
}
|
||||
deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
|
||||
}
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
@@ -390,18 +397,23 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(a) || !digits(b) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
if seen.insert(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
@@ -536,7 +548,7 @@ fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "file.render")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
@@ -547,7 +559,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
|
||||
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "slide.colorAt")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide.colorAt", format!("no slide {i}")));
|
||||
}
|
||||
@@ -578,7 +590,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
let slides: Vec<usize> = match opt_usize_param(p, "slide", "file.export")? {
|
||||
Some(i) => vec![i],
|
||||
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
|
||||
None => vec![st.selection.slide],
|
||||
@@ -763,7 +775,24 @@ mod color_at_tests;
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::write_file;
|
||||
use super::{parse_slide_range, write_file};
|
||||
|
||||
#[test]
|
||||
fn broad_and_repeated_slide_ranges_expand_once() {
|
||||
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
|
||||
let text = vec!["1-100000"; 200].join(",");
|
||||
let v = parse_slide_range(&text, 100_000).unwrap();
|
||||
assert_eq!(v.len(), 100_000);
|
||||
assert!(v.windows(2).all(|w| w[0] < w[1]));
|
||||
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_range_rejects_plus_sign() {
|
||||
assert!(parse_slide_range("+2", 3).is_err());
|
||||
assert!(parse_slide_range("+1-+3", 3).is_err());
|
||||
assert_eq!(parse_slide_range("1-3", 3).unwrap(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
use super::text::{format_body, format_para, format_run, format_run_levels};
|
||||
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,
|
||||
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Numbering",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}",
|
||||
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n (a numbered paragraph whose start differs from that of the last numbered paragraph at its level begins a new run of numbers at n, as PowerPoint's Start at does), color?, size?: % of text}",
|
||||
has_text_or_shapes,
|
||||
numbering
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
|
||||
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
|
||||
let _ = st;
|
||||
let base = ctx_sizes;
|
||||
format_run(s, &move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
})
|
||||
// A layout/master level steps from its own effective size, not the first run's.
|
||||
format_run_levels(
|
||||
s,
|
||||
&move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
&move |r, eff| {
|
||||
let cur = r.size.or(eff.size).unwrap_or(18.0);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -227,6 +235,9 @@ pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad("format.highlight", "`color` is not a valid colour"));
|
||||
}
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
@@ -45,12 +46,18 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
|
||||
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
|
||||
if explicit_char {
|
||||
para.props.bullet_font = Some("Arial".into());
|
||||
para.props.bullet_font_follow_text = None;
|
||||
}
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_points = None;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
|
||||
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
|
||||
@@ -76,9 +83,12 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_points = None;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-27.0);
|
||||
@@ -177,7 +187,8 @@ fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,6 +200,7 @@ fn align_name(a: Align) -> &'static str {
|
||||
Align::Justify => "justify",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "distributed",
|
||||
Align::ThaiDistributed => "thaiDist",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
|
||||
use super::{CommandSpec, bad, cmd, has_slide, opt_usize_param, str_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -245,13 +245,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
|
||||
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "design.ideas")?.unwrap_or(st.selection.slide);
|
||||
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
|
||||
let slide = usize_param(p, "slide");
|
||||
let slide = opt_usize_param(p, "slide", "design.applyIdea")?;
|
||||
s.edit(|doc, sel| {
|
||||
let i = slide.unwrap_or(sel.slide);
|
||||
apply_idea(doc, sel, i, &id)?;
|
||||
@@ -372,6 +372,19 @@ mod tests {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_slide_index_is_refused_not_replaced_by_the_selection() {
|
||||
let mut s = deck(false);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for bad_slide in [json!(-1), json!(1.5), json!("0")] {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
assert!(s.execute("design.ideas", &json!({"slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc) || *before == *s.doc().unwrap().doc);
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": 0})).is_ok());
|
||||
assert!(s.execute("design.ideas", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Date & Time",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog",
|
||||
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog; in the Handout or Notes Master view it fills the selected date placeholder (else the master's own) and answers where: \"master\"",
|
||||
has_slide,
|
||||
date_time
|
||||
),
|
||||
@@ -187,9 +187,10 @@ pub(crate) fn auto_name_base(shape: &Shape) -> String {
|
||||
}
|
||||
|
||||
/// "{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()
|
||||
/// so a gap left by a deleted shape never makes a second shape with the same name. `count` is the
|
||||
/// number of shapes, which exceeds the number of names when two shapes share one.
|
||||
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>, count: usize) -> String {
|
||||
(count + 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> {
|
||||
@@ -197,8 +198,8 @@ pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bo
|
||||
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
if shape.name.is_empty() {
|
||||
let base = auto_name_base(&shape);
|
||||
let taken = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken);
|
||||
let (taken, count) = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken, count);
|
||||
}
|
||||
let id = shape.id;
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
|
||||
@@ -449,6 +450,17 @@ mod tests {
|
||||
assert_eq!(undo(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_british_date_puts_the_day_first() {
|
||||
let mut s = session();
|
||||
edit_title(&mut s);
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-GB"})).unwrap();
|
||||
let text = v["text"].as_str().unwrap().to_string();
|
||||
assert!(text.chars().next().is_some_and(|c| c.is_ascii_digit()) && !text.contains(','), "{text}");
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-US"})).unwrap();
|
||||
assert!(v["text"].as_str().is_some_and(|t| t.contains(',')), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_format_in_french_and_a_bad_value_change_nothing() {
|
||||
let mut s = session();
|
||||
@@ -473,6 +485,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ZZ1: in the handout and notes master views the date goes into the master's date placeholder
|
||||
/// (not the current slide), so the exported handout and notes pages show it.
|
||||
#[test]
|
||||
fn on_a_page_master_the_date_fills_its_date_placeholder() {
|
||||
for view in ["view.handoutMaster", "view.notesMaster"] {
|
||||
let mut s = session();
|
||||
s.execute(view, &json!({})).unwrap();
|
||||
let (id, master) = {
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap().clone();
|
||||
(m.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).unwrap().id.0, m)
|
||||
};
|
||||
assert!(master.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()));
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap();
|
||||
assert_eq!(v["where"], "master", "{view}: {v}");
|
||||
assert!(date_run(&s).is_none(), "the slide keeps no date");
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
let run = m
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| x.id.0 == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{view}: {run:?}");
|
||||
let placed = deckcraft_render::place_master_texts(&st.doc, m, 1);
|
||||
assert!(placed.iter().any(|p| p.body.text() == run.text && run.text.contains(':')), "{view}: the page shows the date");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
assert!(m.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()), "one undo");
|
||||
// A fixed date in a placeholder whose run is en-GB reads day first, and the answer
|
||||
// reports the text that was written, not the en-US sample.
|
||||
s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap();
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
let run = m
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| x.id.0 == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert_eq!(run.props.lang.as_deref(), Some("en-GB"), "{view}: {run:?}");
|
||||
assert_eq!(v["text"].as_str(), Some(run.text.as_str()), "{view}: the answer reports the written text: {v}");
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().unwrap();
|
||||
let gb = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now);
|
||||
assert_eq!(Some(run.text.as_str()), gb.as_deref(), "{view}: the date in en-GB order (dd/mm/yyyy)");
|
||||
// No date placeholder: refused, nothing changed.
|
||||
let key = if view == "view.handoutMaster" { "handout.placeholders" } else { "notes.placeholders" };
|
||||
s.execute(key, &json!({"date": false})).unwrap();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
assert!(s.execute("insert.dateTime", &json!({"format": "datetime1"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_caret_the_date_lands_on_this_slide_only() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -152,6 +152,11 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
}
|
||||
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
|
||||
}
|
||||
// Handout or Notes Master view: the master's date placeholder (the selected one, else the
|
||||
// master's own), which every handout or notes page then shows.
|
||||
if s.doc()?.selection.target.is_page_master() {
|
||||
return write_master_date(s, o);
|
||||
}
|
||||
// Not editing: the slide's date placeholder (this slide only). A layout with no date
|
||||
// placeholder gets a text box instead, so the chosen format is still inserted.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
|
||||
@@ -161,6 +166,49 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Put the date in the date placeholder of the handout or notes master being edited.
|
||||
fn write_master_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let (format, sample, lang, update) = (o.format.clone(), o.sample.clone(), o.lang.clone(), o.update);
|
||||
let has_date = {
|
||||
let st = s.doc()?;
|
||||
crate::page_master(&st.doc, st.selection.target).is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)))
|
||||
};
|
||||
if !has_date {
|
||||
return Err(bad(
|
||||
"insert.dateTime",
|
||||
"this master has no date placeholder; show it with handout.placeholders or notes.placeholders {date: true}",
|
||||
));
|
||||
}
|
||||
// The text the master holds afterwards, which the answer reports (it can differ from the
|
||||
// caller's sample when the placeholder's own language formats a fixed date).
|
||||
let mut written = o.sample.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let Some(m) = crate::page_master_mut(doc, sel.target) else { return Ok(false) };
|
||||
let m = std::sync::Arc::make_mut(m);
|
||||
let date = |x: &deckcraft_model::Shape| x.ph_type() == Some(deckcraft_model::PhType::Date);
|
||||
let chosen = sel.shapes.iter().find_map(|id| m.shapes.iter().position(|x| x.id == *id && date(x)));
|
||||
let Some(i) = chosen.or_else(|| m.shapes.iter().position(date)) else { return Ok(false) };
|
||||
let Some(sh) = m.shapes.get_mut(i) else { return Ok(false) };
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
// A fixed date reads in the placeholder's own language when the caller named none.
|
||||
let text = match (&lang, &props.lang) {
|
||||
(None, Some(l)) if !update => date_sample(&format, Some(l)).unwrap_or_else(|_| sample.clone()),
|
||||
_ => sample.clone(),
|
||||
};
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
let mut para = body.paragraphs.first().cloned().unwrap_or_default();
|
||||
written.clone_from(&text);
|
||||
para.runs = vec![deckcraft_model::Run { text, props, kind }];
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})?;
|
||||
Ok(json!({"where": "master", "format": o.format, "update": o.update, "text": written}))
|
||||
}
|
||||
|
||||
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
|
||||
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
|
||||
let format = o.format.clone();
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::Hyperlink;
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, opt_usize_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn actions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "index", "slide.actions")?.unwrap_or(st.selection.slide);
|
||||
let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?;
|
||||
let mut found = Vec::new();
|
||||
for sh in &sl.shapes {
|
||||
collect_actions(sh, &mut Vec::new(), &mut found);
|
||||
}
|
||||
Ok(json!({"index": i, "slideId": sl.id, "actions": found}))
|
||||
}
|
||||
|
||||
fn collect_actions(sh: &Shape, path: &mut Vec<Value>, found: &mut Vec<Value>) {
|
||||
path.push(json!({"id": sh.id, "name": sh.name}));
|
||||
let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path});
|
||||
if let Some(link) = &sh.click {
|
||||
found.push(action_entry(owner.clone(), "click", link));
|
||||
}
|
||||
if let Some(link) = &sh.hover {
|
||||
found.push(action_entry(owner.clone(), "hover", link));
|
||||
}
|
||||
if let Some(text) = &sh.text {
|
||||
collect_text_actions(text, &owner, found);
|
||||
}
|
||||
if let ShapeKind::Table(table) = &sh.kind {
|
||||
for (row_index, row) in table.rows.iter().enumerate() {
|
||||
for (cell_index, cell) in row.cells.iter().enumerate() {
|
||||
let mut cell_owner = owner.clone();
|
||||
cell_owner["tableRowIndex"] = json!(row_index);
|
||||
cell_owner["tableCellIndex"] = json!(cell_index);
|
||||
collect_text_actions(&cell.text, &cell_owner, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind {
|
||||
for child in children {
|
||||
collect_actions(child, path, found);
|
||||
}
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
|
||||
fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec<Value>) {
|
||||
for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() {
|
||||
for (run_index, run) in paragraph.runs.iter().enumerate() {
|
||||
for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] {
|
||||
if let Some(link) = link {
|
||||
let mut text_owner = owner.clone();
|
||||
text_owner["paragraphIndex"] = json!(paragraph_index);
|
||||
text_owner["runIndex"] = json!(run_index);
|
||||
text_owner["text"] = json!(run.content());
|
||||
found.push(action_entry(text_owner, event, link));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value {
|
||||
let action = serde_json::to_value(&link.action).unwrap_or_default();
|
||||
let kind = action.get("kind").cloned().unwrap_or(Value::Null);
|
||||
let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null);
|
||||
json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link})
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id =
|
||||
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::table::Row;
|
||||
use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody};
|
||||
use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table};
|
||||
|
||||
fn link(action: Action) -> Hyperlink {
|
||||
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_lists_group_shape_and_text_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() };
|
||||
child.click = Some(link(Action::NextSlide));
|
||||
child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() }));
|
||||
let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() };
|
||||
let mut paragraph = Paragraph::new("linked");
|
||||
paragraph.runs = vec![Run::with("linked", run_props)];
|
||||
child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() });
|
||||
let group = Shape {
|
||||
id: ShapeId(11),
|
||||
name: "Group".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide_index = st.selection.slide;
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group];
|
||||
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let actions = v["actions"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 3, "{v}");
|
||||
assert_eq!(actions[0]["event"], "click");
|
||||
assert_eq!(actions[0]["type"], "nextSlide");
|
||||
assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group");
|
||||
assert_eq!(actions[0]["owner"]["path"][1]["id"], 12);
|
||||
assert_eq!(actions[1]["event"], "hover");
|
||||
assert_eq!(actions[1]["target"], "https://example.com/hover");
|
||||
assert_eq!(actions[2]["event"], "textLink");
|
||||
assert_eq!(actions[2]["owner"]["runIndex"], 0);
|
||||
assert_eq!(actions[2]["target"], 7);
|
||||
|
||||
let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() };
|
||||
let mut cell_text = TextBody::from_text("cell link");
|
||||
cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)];
|
||||
let table =
|
||||
Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() };
|
||||
let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let st = s.doc_mut().unwrap();
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape);
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap();
|
||||
assert_eq!(table_action["event"], "textHover");
|
||||
assert_eq!(table_action["owner"]["tableRowIndex"], 0);
|
||||
assert_eq!(table_action["owner"]["tableCellIndex"], 0);
|
||||
assert_eq!(table_action["target"], "https://example.com/cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_returns_empty_array_for_slide_without_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
assert_eq!(v["actions"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_refuses_a_bad_index_instead_of_using_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
for index in [json!(-1), json!("0"), json!(1.5)] {
|
||||
assert!(s.execute("slide.actions", &json!({"index": index})).is_err(), "{index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
list.insert(at + k, sh);
|
||||
}
|
||||
list.retain(|x| !ids.contains(&x.id));
|
||||
sel.shapes = new_ids.clone();
|
||||
let _ = list;
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
let sl = std::sync::Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !ids.contains(&a.shape));
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
}
|
||||
sel.shapes = new_ids.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
@@ -338,4 +347,36 @@ mod tests {
|
||||
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
|
||||
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
|
||||
for op in ["union", "combine", "fragment", "intersect", "subtract"] {
|
||||
let mut s = Session::with_new();
|
||||
let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
|
||||
s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
|
||||
|
||||
s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
|
||||
let animations = &s.doc().expect("doc").doc.slides[0].animations;
|
||||
assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
|
||||
assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
|
||||
assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
|
||||
|
||||
s.execute("edit.undo", &json!({})).expect("undo merge");
|
||||
let animations = &s.doc().expect("doc").doc.slides[0].animations;
|
||||
assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
|
||||
assert_eq!(animations[0].shape.0, second);
|
||||
assert_eq!(animations[1].shape.0, unrelated);
|
||||
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
|
||||
let e = Effects {
|
||||
outer_shadow: lk.shadow.map(shadow),
|
||||
soft_edge: lk.soft_edge,
|
||||
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
reflection: lk.reflection.then_some(Reflection {
|
||||
blur: 0.5,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.35,
|
||||
dist: 0.0,
|
||||
..Default::default()
|
||||
}),
|
||||
bevel: lk.bevel.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -6,16 +6,155 @@
|
||||
//! renumbered with animations, triggers and connector ends following them. Links to another
|
||||
//! reused slide follow its copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::table::TableStyleDef;
|
||||
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
/// Scale physical measurements in imported content to the destination slide's coordinate space.
|
||||
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
|
||||
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
|
||||
let unit = (sx * sy).sqrt();
|
||||
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
|
||||
*v *= sx;
|
||||
}
|
||||
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
|
||||
*v *= sy;
|
||||
}
|
||||
scale_list(&mut t.list_style, sx, unit);
|
||||
for p in &mut t.paragraphs {
|
||||
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(stops) = &mut p.props.tabs {
|
||||
for stop in stops {
|
||||
stop.pos *= sx;
|
||||
}
|
||||
}
|
||||
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
|
||||
if let Some(Spacing::Pts(v)) = spacing {
|
||||
*v *= unit;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut p.props.bullet_size_points {
|
||||
*v *= unit;
|
||||
}
|
||||
scale_run(&mut p.def_props, unit);
|
||||
scale_run(&mut p.end_props, unit);
|
||||
for r in &mut p.runs {
|
||||
scale_run(&mut r.props, unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
|
||||
for level in list.levels.iter_mut().flatten() {
|
||||
for v in
|
||||
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
|
||||
{
|
||||
*v *= sx;
|
||||
}
|
||||
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
|
||||
if let Some(Spacing::Pts(v)) = spacing {
|
||||
*v *= unit;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut level.para.bullet_size_points {
|
||||
*v *= unit;
|
||||
}
|
||||
scale_run(&mut level.run, unit);
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_run(run: &mut RunProps, factor: f64) {
|
||||
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
|
||||
*v *= factor;
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
let unit = (sx * sy).sqrt();
|
||||
for shape in shapes {
|
||||
if let Some(x) = &mut shape.xfrm {
|
||||
x.x *= sx;
|
||||
x.w *= sx;
|
||||
x.y *= sy;
|
||||
x.h *= sy;
|
||||
}
|
||||
if let Some(text) = &mut shape.text {
|
||||
scale_text(text, sx, sy);
|
||||
}
|
||||
match &mut shape.kind {
|
||||
ShapeKind::Table(table) => {
|
||||
for col in &mut table.cols {
|
||||
*col *= sx;
|
||||
}
|
||||
for row in &mut table.rows {
|
||||
row.height *= sy;
|
||||
for cell in &mut row.cells {
|
||||
if let Some(margins) = &mut cell.margins {
|
||||
if let Some(v) = margins.get_mut(0) {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(1) {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(2) {
|
||||
*v *= sy;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(3) {
|
||||
*v *= sy;
|
||||
}
|
||||
}
|
||||
scale_text(&mut cell.text, sx, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapeKind::Group { children, child } => {
|
||||
child.x *= sx;
|
||||
child.w *= sx;
|
||||
child.y *= sy;
|
||||
child.h *= sy;
|
||||
scale_shapes(children, sx, sy, depth + 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(effects) = &mut shape.effects {
|
||||
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
|
||||
shadow.blur *= unit;
|
||||
shadow.dist *= unit;
|
||||
}
|
||||
if let Some(glow) = &mut effects.glow {
|
||||
glow.radius *= unit;
|
||||
}
|
||||
if let Some(reflection) = &mut effects.reflection {
|
||||
reflection.blur *= unit;
|
||||
reflection.dist *= unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
|
||||
scale_shapes(&mut master.shapes, sx, sy, 0);
|
||||
let unit = (sx * sy).sqrt();
|
||||
scale_list(&mut master.title_style, sx, unit);
|
||||
scale_list(&mut master.body_style, sx, unit);
|
||||
scale_list(&mut master.other_style, sx, unit);
|
||||
for layout in &mut master.layouts {
|
||||
scale_shapes(&mut layout.shapes, sx, sy, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec<String>, seen: &mut HashSet<String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &shape.kind
|
||||
&& seen.insert(table.style.to_ascii_lowercase())
|
||||
{
|
||||
styles.push(table.style.clone());
|
||||
}
|
||||
collect_table_styles(shape.children(), styles, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap<String, String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &mut shape.kind
|
||||
&& let Some(style) = styles.get(&table.style.to_ascii_lowercase())
|
||||
{
|
||||
table.style = style.clone();
|
||||
}
|
||||
if let Some(children) = shape.children_mut() {
|
||||
remap_table_styles(children, styles, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
|
||||
a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
|
||||
}
|
||||
|
||||
/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
|
||||
/// existing destination tables continue to resolve to their original definitions.
|
||||
fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result<HashMap<String, String>> {
|
||||
let mut map = HashMap::new();
|
||||
let mut source_defs = HashMap::new();
|
||||
let mut source_ids = HashSet::new();
|
||||
for (index, style) in src.table_styles.iter().enumerate() {
|
||||
let key = style.id.to_ascii_lowercase();
|
||||
source_defs.entry(key.clone()).or_insert(index);
|
||||
source_ids.insert(key);
|
||||
}
|
||||
let mut destination_defs = HashMap::new();
|
||||
for (index, style) in doc.table_styles.iter().enumerate() {
|
||||
destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
|
||||
}
|
||||
for id in used {
|
||||
let key = id.to_ascii_lowercase();
|
||||
if map.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let Some(source_index) = source_defs.get(&key).copied() else { continue };
|
||||
let Some(source) = src.table_styles.get(source_index) else { continue };
|
||||
let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
|
||||
let mut imported = source.clone();
|
||||
if let Some(existing) = destination {
|
||||
if same_table_style(existing, source) {
|
||||
map.insert(key, existing.id.clone());
|
||||
continue;
|
||||
}
|
||||
let candidate = loop {
|
||||
let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
|
||||
let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
|
||||
let key = candidate.to_ascii_lowercase();
|
||||
if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
// The definition's own XML names it too, and PPTX export writes that XML as is.
|
||||
imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
|
||||
imported.id.clone_from(&candidate);
|
||||
map.insert(key, candidate);
|
||||
} else {
|
||||
map.insert(key, imported.id.clone());
|
||||
}
|
||||
destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
|
||||
doc.table_styles.push(imported);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let keep = bool_or(p, "keepFormatting", false);
|
||||
let at = usize_param(p, "at");
|
||||
let source_size = src.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
|
||||
let mut used_table_styles = Vec::new();
|
||||
let mut seen_table_styles = HashSet::new();
|
||||
for &i in &picked {
|
||||
if let Some(slide) = src.slides.get(i) {
|
||||
collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
|
||||
}
|
||||
}
|
||||
let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
|
||||
let scale = |source: f64, destination: f64| {
|
||||
let ratio = destination / source;
|
||||
if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
|
||||
};
|
||||
let sx = scale(source_size.width, doc.slide_size.width);
|
||||
let sy = scale(source_size.height, doc.slide_size.height);
|
||||
// Media: what the slides use is copied under fresh ids.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Keep Source Formatting: bring each source master used, once, with fresh ids.
|
||||
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
scale_master(&mut m, sx, sy);
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
remap_table_styles(&mut sl.shapes, &table_style_map, 0);
|
||||
scale_shapes(&mut sl.shapes, sx, sy, 0);
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_geom::{Size, Xfrm};
|
||||
use deckcraft_model::style::{Artistic, Fill, PictureFill};
|
||||
use deckcraft_model::table::TableStyleDef;
|
||||
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
|
||||
|
||||
fn styled_table_presentation(id: &str, color: &str) -> Presentation {
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let raw = format!(
|
||||
r#"<a:tblStyle xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" styleId="{id}" styleName="Owned"><a:wholeTbl><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle>"#
|
||||
);
|
||||
p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
|
||||
let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
|
||||
let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let group = deckcraft_model::Shape {
|
||||
id: ShapeId(p.alloc_id().expect("group id")),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = p.slides.first_mut().expect("default slide");
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
p
|
||||
}
|
||||
|
||||
fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option<String> {
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &shape.kind {
|
||||
return Some(table.style.clone());
|
||||
}
|
||||
if let Some(style) = first_table_style(shape.children()) {
|
||||
return Some(style);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
|
||||
for (name, bytes) in [
|
||||
("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
|
||||
("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
|
||||
] {
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
let mut session = Session::with_new();
|
||||
session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
|
||||
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
|
||||
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
|
||||
session.execute("edit.undo", &json!({})).expect("undo reuse");
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
|
||||
session.execute("edit.redo", &json!({})).expect("redo reuse");
|
||||
let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
|
||||
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
|
||||
assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
|
||||
assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
|
||||
let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
|
||||
let source_bytes = deckcraft_format::save(&source).expect("save source");
|
||||
|
||||
let mut identical = Session::with_new();
|
||||
identical
|
||||
.edit(|doc, _| {
|
||||
doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed identical definition");
|
||||
identical
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
|
||||
.expect("reuse identical definition");
|
||||
let doc = &identical.doc().expect("doc").doc;
|
||||
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
|
||||
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
|
||||
let mut conflicting = Session::with_new();
|
||||
conflicting
|
||||
.edit(|doc, _| {
|
||||
let mut own = source.table_styles.first().cloned().unwrap_or_default();
|
||||
own.raw = own.raw.replace("FF0000", "0000FF");
|
||||
doc.table_styles.push(own);
|
||||
let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
|
||||
let id = ShapeId(doc.alloc_id().expect("destination table id"));
|
||||
if let Some(slide) = doc.slides.first_mut() {
|
||||
Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed conflicting definition");
|
||||
conflicting
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
|
||||
.expect("reuse conflicting definition");
|
||||
let doc = &conflicting.doc().expect("doc").doc;
|
||||
assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
|
||||
assert_ne!(imported_style, CUSTOM_STYLE);
|
||||
assert!(
|
||||
doc.table_styles.iter().any(|style| style.id == imported_style
|
||||
&& style.raw.contains("FF0000")
|
||||
&& style.raw.contains(&format!("styleId=\"{imported_style}\"")))
|
||||
);
|
||||
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_slide_content_fits_destination_size() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
src.slide_size = Size::new(960.0, 540.0);
|
||||
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
|
||||
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
|
||||
let mut text = TextBody::from_text("scaled");
|
||||
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
run.props.size = Some(24.0);
|
||||
}
|
||||
slide.shapes = vec![
|
||||
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
|
||||
Shape {
|
||||
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
|
||||
text: Some(text),
|
||||
kind: ShapeKind::Table({
|
||||
let mut table = Table::new(2, 2, 400.0, 40.0);
|
||||
table.rows[0].cells[0].text = TextBody::from_text("cell");
|
||||
if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
run.props.size = Some(18.0);
|
||||
}
|
||||
table
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Shape {
|
||||
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
|
||||
kind: ShapeKind::Group {
|
||||
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
|
||||
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
}
|
||||
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
|
||||
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
|
||||
if let Some(layout) = master.layouts.first_mut() {
|
||||
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
|
||||
}
|
||||
}
|
||||
let bytes = deckcraft_format::save(&src).expect("source save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
let mut session = Session::with_new();
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
doc.slide_size = Size::new(480.0, 270.0);
|
||||
Ok(())
|
||||
})
|
||||
.expect("destination size");
|
||||
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
|
||||
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
let reused = doc.slides.get(1).expect("reused slide");
|
||||
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
|
||||
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
|
||||
let table_shape = reused.shapes.get(1).expect("table");
|
||||
let table_box = table_shape.xfrm.expect("table transform");
|
||||
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
|
||||
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
|
||||
let ShapeKind::Table(table) = &table_shape.kind else { return };
|
||||
assert_eq!(table.cols, vec![100.0, 100.0]);
|
||||
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
|
||||
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
|
||||
assert_eq!(font_size, Some(12.0));
|
||||
let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
|
||||
assert_eq!(cell_size, Some(9.0));
|
||||
let group = reused.shapes.get(2).expect("group");
|
||||
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
|
||||
let ShapeKind::Group { children, child } = &group.kind else { return };
|
||||
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
|
||||
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
|
||||
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
|
||||
|
||||
let imported_master = doc.masters.last().expect("imported master");
|
||||
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
|
||||
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
|
||||
assert_ne!(reused.id, slide_id);
|
||||
|
||||
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
|
||||
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
|
||||
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
|
||||
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
|
||||
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
|
||||
#[test]
|
||||
|
||||
+166
-18
@@ -58,9 +58,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
insert_synonym
|
||||
),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare the open deck with another file → {name, changes: [{kind, slide, number, onlyIn?, revised, details}]}; each change is described from the open deck: `onlyIn: \"deck\"` is in the deck and not in the file, `onlyIn: \"file\"` is in the file and not in the deck, `changed` is in both and differs; `slide` is 0-based, `number` the editor's slide number; review.accept takes the FILE's side, so it deletes what is only in the deck (Accept All keeps the deck's last remaining slide rather than deleting it)", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide (0-based), number (editor's), onlyIn?: deck|file, revised, details}]}; `onlyIn: deck` = only in the open deck, `file` = only in the compared file; accepting takes the file's side", has_doc, revisions),
|
||||
cmd!(
|
||||
"review.accept",
|
||||
"Accept Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} — accept the compared file's side of a change (or all): what is only in the file is added, what is only in the open deck is deleted (all: true leaves the deck's last remaining slide in place), a changed slide becomes the file's",
|
||||
comparing,
|
||||
accept
|
||||
),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
@@ -264,19 +272,31 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(bg) = bg {
|
||||
for sh in &sl.shapes {
|
||||
let Some(t) = &sh.text else { continue };
|
||||
let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue };
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default());
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
let Some(t) = &sh.text else { return };
|
||||
let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh);
|
||||
if fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let fg = deckcraft_model::resolve::text_color(&ctx, &rp);
|
||||
if fg.contrast(bg) < 3.0 {
|
||||
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))}));
|
||||
let low = t
|
||||
.paragraphs
|
||||
.iter()
|
||||
.flat_map(|para| {
|
||||
// A line break or blank run shows no glyphs, so its colour can't be hard to read.
|
||||
para.runs.iter().filter(|run| run.kind != deckcraft_model::text::RunKind::Break && !run.text.trim().is_empty()).map(
|
||||
|run| {
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &run.props);
|
||||
deckcraft_model::resolve::text_color(&ctx, &rp).contrast(bg)
|
||||
},
|
||||
)
|
||||
})
|
||||
.find(|contrast| *contrast < 3.0);
|
||||
if let Some(contrast) = low {
|
||||
out.push(
|
||||
json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({contrast:.1}:1)")}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
for w in crate::spell::paragraph_words(para, &default) {
|
||||
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
|
||||
continue;
|
||||
@@ -401,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
|
||||
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
// The result is built in one pass; the text is not edited in place for every match.
|
||||
let mut out = String::new();
|
||||
let (mut n, mut at, mut copied) = (0, 0, 0);
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
// The character before the match as the edited text would have it.
|
||||
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
out.push_str(text.get(copied..i).unwrap_or_default());
|
||||
out.push_str(to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
(at, copied) = (end, end);
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
out.push_str(text.get(copied..).unwrap_or_default());
|
||||
*text = out;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
@@ -744,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The replacement as it ran before: edited in place for every match.
|
||||
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_word_matches_the_in_place_version() {
|
||||
let cases = [
|
||||
("a a b a", "a", "x", true),
|
||||
("aa a. a", "a", "bb", true),
|
||||
("a-a -a-", "-", "ab", true),
|
||||
("foo. foo.", "foo.", "", true),
|
||||
("it's it", "it", "so", true),
|
||||
("x.x. x.", "x.", "y", true),
|
||||
("a a a", "a", "b", false),
|
||||
("none", "z", "q", true),
|
||||
("é é", "é", "e", true),
|
||||
];
|
||||
for (text, from, to, all) in cases {
|
||||
let (mut a, mut b) = (text.to_string(), text.to_string());
|
||||
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
|
||||
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -860,3 +930,81 @@ mod spelling_change_range_tests {
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod accessibility_contrast_tests {
|
||||
use super::*;
|
||||
use deckcraft_model::text::{Paragraph, Run, RunKind, RunProps};
|
||||
use deckcraft_model::{ColorRef, Fill, Rgba, Shape, ShapeId, ShapeKind, TextBody};
|
||||
use serde_json::json;
|
||||
|
||||
fn run(text: &str, color: Rgba) -> Run {
|
||||
Run::with(text, RunProps { fill: Some(Fill::solid(ColorRef::rgb(color))), ..Default::default() })
|
||||
}
|
||||
|
||||
fn text_shape(id: u32, name: &str, paragraphs: Vec<Paragraph>) -> Shape {
|
||||
Shape { id: ShapeId(id), name: name.into(), text: Some(TextBody { paragraphs, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_checks_mixed_runs_later_paragraphs_and_group_descendants_once() {
|
||||
let mut s = Session::with_new();
|
||||
s.edit(|doc, _| {
|
||||
let black = Rgba::BLACK;
|
||||
let white = Rgba::WHITE;
|
||||
let mixed = Paragraph { runs: vec![run("visible", black), run("hidden", white)], ..Default::default() };
|
||||
let later = vec![
|
||||
Paragraph { runs: vec![run("visible", black)], ..Default::default() },
|
||||
Paragraph { runs: vec![run("hidden", white)], ..Default::default() },
|
||||
];
|
||||
let child = text_shape(5, "nested text", vec![Paragraph { runs: vec![run("hidden", white)], ..Default::default() }]);
|
||||
let group = Shape {
|
||||
id: ShapeId(4),
|
||||
name: "group".into(),
|
||||
descr: "group of text".into(),
|
||||
kind: ShapeKind::Group {
|
||||
children: vec![Shape {
|
||||
id: ShapeId(7),
|
||||
name: "nested group".into(),
|
||||
descr: "nested group of text".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
}],
|
||||
child: Default::default(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(0).ok_or_else(|| bad("test", "no slide"))?);
|
||||
sl.shapes = vec![
|
||||
text_shape(2, "mixed", vec![mixed]),
|
||||
text_shape(3, "later paragraph", later),
|
||||
group,
|
||||
text_shape(6, "black control", vec![Paragraph { runs: vec![run("visible", black)], ..Default::default() }]),
|
||||
text_shape(
|
||||
8,
|
||||
"invisible white runs",
|
||||
vec![Paragraph {
|
||||
runs: vec![run("visible", black), Run { kind: RunKind::Break, ..run("\n", white) }, run(" ", white), run("visible", black)],
|
||||
..Default::default()
|
||||
}],
|
||||
),
|
||||
];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let issues = s.execute("review.accessibility", &json!({})).unwrap();
|
||||
let contrasts: Vec<_> = issues
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|issue| issue["issue"].as_str().unwrap_or("").starts_with("Hard-to-read text contrast"))
|
||||
.collect();
|
||||
assert_eq!(contrasts.len(), 3, "{issues}");
|
||||
for id in [2, 3, 5] {
|
||||
assert!(contrasts.iter().any(|issue| issue["shape"] == id), "missing shape {id}: {issues}");
|
||||
}
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 6), "black control warned: {issues}");
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 8), "white break or spaces warned: {issues}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
@@ -14,20 +16,51 @@ const MAX_RUNS: usize = 10_000;
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
out.push_str(&open);
|
||||
if !push_if_fits(&mut out, &open, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
deckcraft_model::text::RunKind::Break => {
|
||||
if !push_if_fits(&mut out, "<br>", close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !push_escaped_bounded(&mut out, &r.text, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&close);
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text without formatting, or "" (no HTML on the clipboard) when even that is too big: a
|
||||
/// cut-off prefix would paste as if it were the whole text.
|
||||
fn plain_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return String::new();
|
||||
}
|
||||
for r in &p.runs {
|
||||
let fits = if matches!(&r.kind, deckcraft_model::text::RunKind::Break) {
|
||||
push_if_fits(&mut out, "<br>", 4)
|
||||
} else {
|
||||
push_escaped_bounded(&mut out, &r.text, 4)
|
||||
};
|
||||
if !fits {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
@@ -35,6 +68,43 @@ pub fn body_to_html(body: &TextBody) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn push_if_fits(out: &mut String, value: &str, reserve: usize) -> bool {
|
||||
if out.len().checked_add(value.len()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push_str(value);
|
||||
true
|
||||
}
|
||||
|
||||
fn push_escaped_bounded(out: &mut String, text: &str, reserve: usize) -> bool {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => {
|
||||
if !push_if_fits(out, "&", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if !push_if_fits(out, "<", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'>' => {
|
||||
if !push_if_fits(out, ">", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if out.len().checked_add(c.len_utf8()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
@@ -53,17 +123,6 @@ fn tags(props: &RunProps) -> (String, String) {
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
@@ -77,7 +136,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut stack: Vec<(Cow<'static, str>, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
@@ -106,23 +165,27 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => name.as_str(),
|
||||
kind => kind,
|
||||
};
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0.as_ref() == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (db, di, du) = (stack[pos].1, stack[pos].2, stack[pos].3);
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
@@ -133,7 +196,11 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => Cow::Owned(name.clone()),
|
||||
kind => Cow::Borrowed(kind),
|
||||
};
|
||||
stack.push((kind, db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
@@ -368,6 +435,34 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_on_other_tags_ends_at_their_close() {
|
||||
type Prop = fn(&RunProps) -> bool;
|
||||
let cases: [(&str, &str, Prop); 3] = [
|
||||
("a", "font-weight:bold", |p| p.bold == Some(true)),
|
||||
("td", "font-style:italic", |p| p.italic == Some(true)),
|
||||
("mark", "text-decoration:underline", |p| p.underline.is_some()),
|
||||
];
|
||||
for (tag, style, prop) in cases {
|
||||
let body = html_to_body(&format!("<p><{tag} style=\"{style}\">link</{tag}> plain</p>")).unwrap();
|
||||
let runs = &body.paragraphs[0].runs;
|
||||
assert_eq!(runs.len(), 2, "{tag}");
|
||||
assert!(prop(&runs[0].props), "{tag}");
|
||||
assert_eq!(runs[0].text, "link");
|
||||
assert_eq!(runs[1].text, " plain");
|
||||
assert!(runs[1].props.bold.is_none() && runs[1].props.italic.is_none() && runs[1].props.underline.is_none(), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_before_opening_block_tag_is_separate_paragraph() {
|
||||
for tag in ["div", "p", "li", "h1"] {
|
||||
let body = html_to_body(&format!("Hello<{tag}>world</{tag}>")).unwrap();
|
||||
let texts: Vec<String> = body.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect()).collect();
|
||||
assert_eq!(texts, ["Hello", "world"], "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_after_ampersand_is_kept() {
|
||||
let text = "é".repeat(20);
|
||||
@@ -436,6 +531,16 @@ mod tests {
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_escaped_rich_text_puts_no_html_on_the_clipboard() {
|
||||
let mut body = TextBody::from_text(&"&<🦀".repeat(100_000));
|
||||
body.paragraphs[0].runs[0].props.bold = Some(true);
|
||||
body.paragraphs.push(Paragraph::new("later paragraph"));
|
||||
|
||||
// Neither the formatted nor the plain HTML fits, and a truncated prefix would lose text.
|
||||
assert!(body_to_html(&body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
has_selection,
|
||||
fill
|
||||
),
|
||||
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Effects",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
|
||||
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, size?: 0.01..2 (1 = 100%), blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
|
||||
has_selection,
|
||||
effects
|
||||
),
|
||||
@@ -165,7 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
picture_adjust
|
||||
),
|
||||
cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset),
|
||||
cmd!(
|
||||
"picture.reset",
|
||||
"Reset Picture",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{ids?} (discards the picture's formatting: adjustments, artistic effect, crop, transparency, picture-style effects and outline; the frame shape from Change Shape or Crop to Shape, the size and the position stay)",
|
||||
has_selection,
|
||||
picture_reset
|
||||
),
|
||||
cmd!(
|
||||
"picture.change",
|
||||
"Change Picture",
|
||||
@@ -253,6 +261,9 @@ fn requested_fill(s: &mut Session, p: &Value) -> Result<Option<Option<Fill>>> {
|
||||
if bool_or(p, "none", false) {
|
||||
return Ok(Some(Some(Fill::None)));
|
||||
}
|
||||
if bool_or(p, "background", false) {
|
||||
return Ok(Some(Some(Fill::Background)));
|
||||
}
|
||||
if let Some(c) = color_param(p, "color") {
|
||||
return Ok(Some(Some(Fill::solid(c))));
|
||||
}
|
||||
@@ -495,6 +506,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_fill_shows_the_slide_background_and_survives_a_save() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0000c8"})).unwrap();
|
||||
let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap() as u32);
|
||||
let inside = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 200, "y": 150})).unwrap()["color"].clone();
|
||||
assert_ne!(inside(&mut s), "0000C8", "a new shape takes the theme's fill");
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
|
||||
assert_eq!(inside(&mut s), "0000C8", "the slide background shows through the shape");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, None, "one undo step returns to the theme's fill");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// Transparency changes a solid or picture fill only.
|
||||
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let filled = back.slides.iter().flat_map(|sl| sl.shapes.iter()).filter(|sh| sh.fill == Some(Fill::Background)).count();
|
||||
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
|
||||
}
|
||||
|
||||
/// A picture cannot save a slide background fill, so `background` leaves its fill alone.
|
||||
#[test]
|
||||
fn background_fill_skips_pictures() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png();
|
||||
let pic = s
|
||||
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "red.png", "rect": [400, 100, 100, 100]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
|
||||
let err = s.execute("shape.fill", &json!({"background": true, "ids": [pic]})).unwrap_err();
|
||||
assert!(err.to_string().contains("shapes and text boxes"), "{err}");
|
||||
assert_eq!(fill(&s, pic), None);
|
||||
|
||||
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
|
||||
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
assert_eq!(fill(&s, shape), Some(Fill::Background));
|
||||
assert!(matches!(fill(&s, pic), Some(Fill::Solid { .. })), "the picture keeps its fill");
|
||||
assert_eq!(fill(&s, group), None, "the group's own fill is cleared");
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let mut filled = 0;
|
||||
for slide in &back.slides {
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| filled += usize::from(sh.fill == Some(Fill::Background)));
|
||||
}
|
||||
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
|
||||
}
|
||||
|
||||
/// A picture filled with its group's fill keeps that fill behind its transparent pixels when
|
||||
/// the group's shapes take the slide background fill.
|
||||
#[test]
|
||||
fn background_fill_keeps_the_group_fill_a_picture_uses() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png();
|
||||
let pic = s
|
||||
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "clear.png", "rect": [400, 100, 100, 100]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
|
||||
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0))));
|
||||
s.edit(|doc, _| {
|
||||
for slide in &mut doc.slides {
|
||||
let slide = std::sync::Arc::make_mut(slide);
|
||||
if let Some(g) = slide.shape_mut(ShapeId(group as u32)) {
|
||||
g.fill.clone_from(&red);
|
||||
}
|
||||
if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) {
|
||||
p.fill = Some(Fill::Group);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let behind = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 450, "y": 150})).unwrap()["color"].clone();
|
||||
assert_eq!(behind(&mut s), "FF0000", "the group's fill shows through the picture");
|
||||
s.execute("edit.select", &json!({"ids": [group]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
|
||||
assert_eq!(fill(&s, shape), Some(Fill::Background));
|
||||
assert_eq!(fill(&s, pic), Some(Fill::Group));
|
||||
assert_eq!(fill(&s, group), red, "the group keeps the fill its picture uses");
|
||||
assert_eq!(behind(&mut s), "FF0000", "the group's fill still shows through the picture");
|
||||
}
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, usize_param,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -108,7 +108,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
scaled: false,
|
||||
},
|
||||
};
|
||||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||
Some(Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops,
|
||||
shape,
|
||||
rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true),
|
||||
flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
@@ -134,11 +140,47 @@ pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
|
||||
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
|
||||
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
|
||||
return Err(bad(
|
||||
"shape.fill",
|
||||
"give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`",
|
||||
));
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let inherited = inherited_fills(s, p)?;
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited))
|
||||
let background = matches!(new_fill, Some(Some(Fill::Background)));
|
||||
if background {
|
||||
let st = s.doc()?;
|
||||
let mut any = false;
|
||||
for id in targets(s, p)? {
|
||||
if let Some(sh) = st.shape(id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape));
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes"));
|
||||
}
|
||||
}
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||||
if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) {
|
||||
if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) {
|
||||
sh.fill = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)
|
||||
})
|
||||
}
|
||||
|
||||
/// Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures,
|
||||
/// connectors, tables and charts keep their fill, and a group's own fill is cleared unless one
|
||||
/// of them is filled with it (`grpFill`).
|
||||
fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool {
|
||||
depth <= 64
|
||||
&& sh.children().iter().any(|c| match c.kind {
|
||||
deckcraft_model::ShapeKind::Shape => false,
|
||||
deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1),
|
||||
_ => c.fill == Some(Fill::Group),
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
|
||||
@@ -329,9 +371,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
|
||||
let refl = str_param(p, "reflection").map(|r| match r {
|
||||
"none" => None,
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
|
||||
});
|
||||
let three_d = p.get("threeD").filter(|v| v.is_object());
|
||||
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
|
||||
@@ -401,6 +443,12 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = num(v, "dir") {
|
||||
x.dir = d.rem_euclid(360.0);
|
||||
}
|
||||
// PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.
|
||||
if let Some(z) = num(v, "size") {
|
||||
let z = z.clamp(0.01, 2.0);
|
||||
x.sx = z;
|
||||
x.sy = z;
|
||||
}
|
||||
*slot = Some(x);
|
||||
}
|
||||
if let Some(v) = &glow_props {
|
||||
@@ -419,7 +467,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
e.glow = (g.radius > 0.0).then_some(g);
|
||||
}
|
||||
if let Some(v) = &refl_props {
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection {
|
||||
blur: 0.5,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.35,
|
||||
dist: 0.0,
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
|
||||
}
|
||||
@@ -513,3 +568,14 @@ pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gradient_parameters_keep_flip_mode() {
|
||||
let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient");
|
||||
assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy));
|
||||
}
|
||||
}
|
||||
|
||||
+220
-24
@@ -1,5 +1,6 @@
|
||||
//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
@@ -22,7 +23,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate),
|
||||
cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete),
|
||||
cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide),
|
||||
cmd!(
|
||||
"slide.move",
|
||||
"Move Slide",
|
||||
[],
|
||||
None,
|
||||
"{from?: index, ids?: [slide id], to: index (counted without the moved slides)}",
|
||||
has_slide,
|
||||
move_slide
|
||||
),
|
||||
cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go),
|
||||
cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next),
|
||||
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
|
||||
@@ -258,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !ids.contains(x));
|
||||
}
|
||||
for show in &mut doc.custom_shows {
|
||||
show.slides.retain(|x| !ids.contains(x));
|
||||
}
|
||||
sel.slide = first.min(doc.slides.len().saturating_sub(1));
|
||||
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
|
||||
sel.shapes.clear();
|
||||
@@ -266,36 +278,75 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves one slide (`from`, else the current slide) or several (`ids`) to `to`, in one undo step.
|
||||
/// `to` counts in the deck without the moved slides; they land there as one run, in the order
|
||||
/// they had, join the section of the slide before them, and stay selected.
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let ids = p.get("ids").filter(|v| !v.is_null());
|
||||
let from = match ids {
|
||||
None => opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide),
|
||||
Some(_) => 0,
|
||||
};
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
return Err(bad("slide.move", format!("no slide {from}")));
|
||||
}
|
||||
let sl = doc.slides.remove(from);
|
||||
let to = to.min(doc.slides.len());
|
||||
let id = sl.id;
|
||||
doc.slides.insert(to, sl);
|
||||
// Keep sections consistent with order: move the id into the section of its new neighbour.
|
||||
if !doc.sections.is_empty() {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| *x != id);
|
||||
let moving: HashSet<SlideId> = match ids {
|
||||
Some(v) => slide_ids_param(v, doc, "slide.move")?,
|
||||
None => {
|
||||
let id = doc.slides.get(from).map(|x| x.id).ok_or_else(|| bad("slide.move", format!("no slide {from}")))?;
|
||||
HashSet::from([id])
|
||||
}
|
||||
let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.push(id);
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
};
|
||||
let (block, mut rest): (Vec<_>, Vec<_>) = std::mem::take(&mut doc.slides).into_iter().partition(|x| moving.contains(&x.id));
|
||||
let to = to.min(rest.len());
|
||||
let tail = rest.split_off(to);
|
||||
let moved: Vec<SlideId> = block.iter().map(|x| x.id).collect();
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
doc.slides = rest;
|
||||
reassign_sections(doc, &moved, to);
|
||||
sel.slide = to;
|
||||
sel.slides = vec![id];
|
||||
Ok(json!({"index": to}))
|
||||
sel.slides = moved;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"index": to, "moved": moving.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The slides an `ids` param names: a non-empty list of ids of slides in `doc` (repeats fold).
|
||||
/// The first id that is not a slide of `doc` is an error, so the set never outgrows the deck.
|
||||
fn slide_ids_param(v: &Value, doc: &Presentation, cmd: &str) -> Result<HashSet<SlideId>> {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, "`ids` must be a list of slide ids"))?;
|
||||
let known: HashSet<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
let mut out = HashSet::new();
|
||||
for x in a {
|
||||
let id = x.as_u64().and_then(|n| u32::try_from(n).ok()).map(SlideId).filter(|id| known.contains(id));
|
||||
out.insert(id.ok_or_else(|| bad(cmd, format!("no slide with id {x}")))?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, "`ids` is empty"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Keeps sections in step after `moved` landed as a run at `at`: the run joins the section of
|
||||
/// the slide just before it (the first section when it opens the deck), in document order.
|
||||
fn reassign_sections(doc: &mut Presentation, moved: &[SlideId], at: usize) {
|
||||
if doc.sections.is_empty() {
|
||||
return;
|
||||
}
|
||||
let moving: HashSet<SlideId> = moved.iter().copied().collect();
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !moving.contains(x));
|
||||
}
|
||||
let neighbour = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: HashMap<SlideId, usize> = doc.slides.iter().enumerate().map(|(i, x)| (x.id, i)).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.extend_from_slice(moved);
|
||||
sec.slides.sort_by_key(|x| order.get(x).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, i: usize) -> Result<Value> {
|
||||
s.select(|doc, sel| {
|
||||
let n = doc.slides.len();
|
||||
@@ -357,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
s.select(|doc, sel| {
|
||||
if add {
|
||||
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
|
||||
for i in &ids {
|
||||
if !sel.slides.contains(i) {
|
||||
if have.insert(*i) {
|
||||
sel.slides.push(*i);
|
||||
}
|
||||
}
|
||||
@@ -710,6 +762,150 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A deck of `n` slides and its slide ids, in order.
|
||||
fn deck(n: usize) -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < n {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
let ids = order(&s);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<u32> {
|
||||
s.doc().expect("doc").doc.slides.iter().map(|x| x.id.0).collect()
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> (usize, Vec<u32>) {
|
||||
let sel = &s.doc().expect("doc").selection;
|
||||
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let shows = vec![
|
||||
deckcraft_model::CustomShow {
|
||||
name: "First".into(),
|
||||
slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
|
||||
},
|
||||
deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
|
||||
deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
|
||||
];
|
||||
s.edit(|doc, _| {
|
||||
doc.custom_shows = shows;
|
||||
Ok(())
|
||||
})
|
||||
.expect("set custom shows");
|
||||
|
||||
s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
|
||||
let shows = &s.doc().expect("doc").doc.custom_shows;
|
||||
assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
|
||||
assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
|
||||
assert!(shows[2].slides.is_empty());
|
||||
|
||||
s.execute("edit.undo", &json!({})).expect("undo deletion");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(
|
||||
s.doc().expect("doc").doc.custom_shows[0].slides,
|
||||
[deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
|
||||
);
|
||||
assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
|
||||
|
||||
s.execute("edit.redo", &json!({})).expect("redo deletion");
|
||||
let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
|
||||
let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
|
||||
assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
|
||||
assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
|
||||
assert!(reopened.custom_shows[2].slides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_from_to_moves_one_slide() {
|
||||
let (mut s, ids) = deck(4);
|
||||
s.execute("slide.move", &json!({"from": 3, "to": 0})).expect("move");
|
||||
assert_eq!(order(&s), [ids[3], ids[0], ids[1], ids[2]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_ids_lands_them_as_one_run_in_deck_order() {
|
||||
let (mut s, ids) = deck(6);
|
||||
// Listed backwards: they still land in the order they had.
|
||||
s.execute("slide.move", &json!({"ids": [ids[4], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(order(&s), [ids[1], ids[4], ids[0], ids[2], ids[3], ids[5]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[1], ids[4]]), "the moved slides stay selected");
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[2]], "to": 2})).expect("to the middle");
|
||||
assert_eq!(order(&s), [ids[1], ids[3], ids[0], ids[2], ids[4], ids[5]]);
|
||||
assert_eq!(selected(&s), (2, vec![ids[0], ids[2]]));
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[1], ids[3]], "to": 1000})).expect("past the end clamps");
|
||||
assert_eq!(order(&s), [ids[0], ids[2], ids[4], ids[5], ids[1], ids[3]]);
|
||||
assert_eq!(selected(&s), (4, vec![ids[1], ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_several_slides_is_one_undo_step() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("slide.selectSlides", &json!({"ids": [ids[0], ids[3]]})).expect("select");
|
||||
let before = selected(&s);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[3]], "to": 3})).expect("move");
|
||||
let after = order(&s);
|
||||
assert_eq!(after, [ids[1], ids[2], ids[4], ids[0], ids[3]]);
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(selected(&s), before);
|
||||
s.execute("edit.redo", &json!({})).expect("redo");
|
||||
assert_eq!(order(&s), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_slides_join_the_section_before_them() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("section.add", &json!({"name": "A", "at": 0})).expect("A");
|
||||
s.execute("section.add", &json!({"name": "B", "at": 3})).expect("B");
|
||||
// A = 0,1,2; B = 3,4. Move 0 and 1 to the end: B gets them, A keeps only slide 2.
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 3})).expect("move");
|
||||
let sections =
|
||||
|s: &Session| -> Vec<Vec<u32>> { s.doc().expect("doc").doc.sections.iter().map(|x| x.slides.iter().map(|id| id.0).collect()).collect() };
|
||||
assert_eq!(sections(&s), [vec![ids[2]], vec![ids[3], ids[4], ids[0], ids[1]]]);
|
||||
// Move the whole of A away: A stays, empty; the run at the front joins the first section.
|
||||
s.execute("slide.move", &json!({"ids": [ids[2]], "to": 4})).expect("empty A");
|
||||
assert_eq!(sections(&s), [vec![], vec![ids[3], ids[4], ids[0], ids[1], ids[2]]]);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(sections(&s), [vec![ids[0], ids[1]], vec![ids[3], ids[4], ids[2]]]);
|
||||
let all: Vec<u32> = sections(&s).concat();
|
||||
assert_eq!(all, order(&s), "sections follow slide order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ids_are_rejected_and_change_nothing() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let lots_of_unknown: Vec<u64> = (1_000_000..1_100_000).collect();
|
||||
for p in [
|
||||
json!({"ids": [], "to": 0}),
|
||||
json!({"ids": [ids[0], 999_999], "to": 0}),
|
||||
json!({"ids": [-1], "to": 0}),
|
||||
json!({"ids": ["1"], "to": 0}),
|
||||
json!({"ids": [1.5], "to": 0}),
|
||||
json!({"ids": [u64::MAX], "to": 0}),
|
||||
json!({"ids": ids[0], "to": 0}),
|
||||
json!({"ids": lots_of_unknown, "to": 0}),
|
||||
json!({"ids": [ids[1]]}),
|
||||
] {
|
||||
assert!(s.execute("slide.move", &p).is_err(), "slide.move must reject {p}");
|
||||
assert_eq!(order(&s), ids, "{p}");
|
||||
}
|
||||
// Repeats fold into one.
|
||||
let repeated = vec![ids[2]; 100_000];
|
||||
s.execute("slide.move", &json!({"ids": repeated, "to": 0})).expect("repeats");
|
||||
assert_eq!(order(&s), [ids[2], ids[0], ids[1]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[2]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
}));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
|
||||
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align Cell Text",
|
||||
["Table Layout", "Alignment"],
|
||||
None,
|
||||
"{anchor: top|middle|bottom, cells?, id?}",
|
||||
"{anchor?: top|middle|bottom (vertical; top when neither anchor nor align is given), align?: left|center|right|justify (horizontal, applied to every paragraph of the cells), cells?, id?}",
|
||||
has_selection,
|
||||
cell_align
|
||||
),
|
||||
@@ -535,18 +535,43 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::Anchor;
|
||||
let a = match str_param(p, "anchor").unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
// A value that is not a string is refused, not taken as absent.
|
||||
for k in ["align", "anchor"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_null() && !v.is_string()) {
|
||||
return Err(bad("table.cellAlign", format!("`{k}` is a name such as {}", if k == "align" { "left or center" } else { "top or middle" })));
|
||||
}
|
||||
}
|
||||
// Without `align`, the vertical anchor is the whole command (top when not given).
|
||||
let align = match str_param(p, "align") {
|
||||
None => None,
|
||||
Some("left" | "l") => Some(Align::Left),
|
||||
Some("center" | "centre" | "ctr") => Some(Align::Center),
|
||||
Some("right" | "r") => Some(Align::Right),
|
||||
Some("justify" | "just") => Some(Align::Justify),
|
||||
Some(o) => return Err(bad("table.cellAlign", format!("unknown align {o:?}; one of left, center, right, justify"))),
|
||||
};
|
||||
let anchor = match (str_param(p, "anchor"), align) {
|
||||
(None, Some(_)) => None,
|
||||
(a, _) => Some(match a.unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.anchor = Some(a);
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
}
|
||||
if let Some(a) = align {
|
||||
for para in &mut cell.text.paragraphs {
|
||||
para.props.align = Some(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -30,7 +30,7 @@ use editing::{edit, exit};
|
||||
pub(crate) use editing::{insert_field, insert_text, paste_body};
|
||||
pub use formatting::format_range;
|
||||
use formatting::{fonts_used, replace_font};
|
||||
pub(crate) use formatting::{format_body, format_para, format_run, link_text};
|
||||
pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text};
|
||||
pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
|
||||
use ime::{commit, compose};
|
||||
pub(crate) use layout::ctx_for;
|
||||
|
||||
@@ -142,7 +142,7 @@ pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typi
|
||||
|
||||
/// After a space or punctuation, correct the word just typed.
|
||||
pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
if !s.prefs.autocorrect {
|
||||
if !s.prefs.autocorrect && !(typing && s.prefs.ac_lists) {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc()?;
|
||||
@@ -160,6 +160,9 @@ pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if !s.prefs.autocorrect {
|
||||
return Ok(());
|
||||
}
|
||||
let mut word_end = end - 1;
|
||||
let mut start = word_end;
|
||||
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
|
||||
|
||||
@@ -8,22 +8,34 @@ use super::{
|
||||
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
|
||||
/// or sets the formatting for what is typed next at a word boundary.
|
||||
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
|
||||
format_run_levels(s, f, &|r, _| f(r))
|
||||
}
|
||||
|
||||
/// Like [`format_run`], with a separate change for the list-style levels of a layout or master
|
||||
/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at
|
||||
/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value.
|
||||
pub(crate) fn format_run_levels(
|
||||
s: &mut Session,
|
||||
f: &(dyn Fn(&mut RunProps) + Send + Sync),
|
||||
lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync),
|
||||
) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = format_range(st, &t);
|
||||
let collapsed = a == b;
|
||||
// Only the levels of the paragraphs the selected range touches.
|
||||
let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) };
|
||||
edit_text(s, |body, _| {
|
||||
if collapsed {
|
||||
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
|
||||
if levels.is_empty() {
|
||||
ed::format(body, a, b, f);
|
||||
} else {
|
||||
ed::format(body, a, b, f);
|
||||
format_by_level(body, a, b, lf, &levels);
|
||||
}
|
||||
for (l, eff) in &levels {
|
||||
let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default();
|
||||
lf(&mut lvl.run, eff);
|
||||
body.list_style.set(*l, lvl);
|
||||
}
|
||||
})?;
|
||||
if collapsed {
|
||||
// Remember as pending typing props by inserting nothing: handled via end mark for
|
||||
// empty paragraphs; mid-paragraph carets keep surrounding formatting.
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
@@ -31,6 +43,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
// Effective run props of every level of the selected placeholders, resolved before editing.
|
||||
let effective: Vec<(ShapeId, Vec<RunProps>)> = if editing_master(st) {
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
|
||||
ids.iter()
|
||||
.filter_map(|id| {
|
||||
let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?;
|
||||
let ctx = ctx.as_ref()?;
|
||||
Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect()))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
@@ -47,7 +72,22 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
}
|
||||
_ => {
|
||||
let body = sh.text.get_or_insert_with(TextBody::default);
|
||||
ed::format_all(body, f);
|
||||
let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e);
|
||||
match eff {
|
||||
Some(eff) => {
|
||||
let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect();
|
||||
format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels);
|
||||
}
|
||||
None => ed::format_all(body, f),
|
||||
}
|
||||
// The whole shape is selected: every level of its list style.
|
||||
if let Some(eff) = eff {
|
||||
for (l, eff) in (0..LIST_LEVELS).zip(eff) {
|
||||
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
|
||||
lf(&mut lvl.run, eff);
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -58,6 +98,55 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so
|
||||
/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared
|
||||
/// value. `a`/`b` are clamped to the body.
|
||||
fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) {
|
||||
let Some(last) = body.paragraphs.len().checked_sub(1) else { return };
|
||||
let (first, end) = (a.0.min(last), b.0.min(last));
|
||||
for pi in first..=end {
|
||||
let p = &body.paragraphs[pi];
|
||||
let from = if pi == first { a.1.min(p.char_len()) } else { 0 };
|
||||
let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() };
|
||||
if from == to && p.char_len() > 0 {
|
||||
continue;
|
||||
}
|
||||
let lvl = p.level.min(LIST_LEVELS - 1);
|
||||
let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue };
|
||||
ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff));
|
||||
}
|
||||
}
|
||||
|
||||
/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it.
|
||||
const LIST_LEVELS: u8 = 9;
|
||||
|
||||
/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style,
|
||||
/// which is what the placeholders on slides inherit; its own runs may be few or none.
|
||||
fn editing_master(st: &DocState) -> bool {
|
||||
matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. })
|
||||
}
|
||||
|
||||
/// The distinct list-style levels of the paragraphs `range` touches in a layout or master
|
||||
/// placeholder, with each level's effective run props. Empty anywhere else.
|
||||
fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> {
|
||||
if !editing_master(st) || t.notes || t.cell.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (Some(sh), Some(body), Some(ctx)) =
|
||||
(st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut levels: Vec<u8> =
|
||||
body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect();
|
||||
if body.paragraphs.is_empty() {
|
||||
levels.push(0);
|
||||
}
|
||||
levels.sort_unstable();
|
||||
levels.dedup();
|
||||
levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect()
|
||||
}
|
||||
|
||||
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
|
||||
/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink
|
||||
/// edits this same span, so the dialog reads it too.
|
||||
@@ -110,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
// Only the list-style levels of the paragraphs touched, on a layout or master placeholder.
|
||||
let levels: Vec<u8> = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect();
|
||||
edit_text(s, |body, _| {
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
@@ -120,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
f(p);
|
||||
}
|
||||
}
|
||||
for l in levels {
|
||||
format_list_level(body, l, f);
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
@@ -128,8 +222,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
let on_master = editing_master(st);
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
let inherited = on_master && sh.ph.is_some();
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
let range = crate::cmd::table::selected_range(cells, id, t);
|
||||
@@ -147,6 +243,12 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
body.paragraphs.iter_mut().for_each(f);
|
||||
// The whole shape is selected: every level of its list style.
|
||||
if inherited {
|
||||
for l in 0..LIST_LEVELS {
|
||||
format_list_level(body, l, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -157,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Apply a paragraph change to one level of a body's list style.
|
||||
fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) {
|
||||
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
|
||||
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
|
||||
f(&mut p);
|
||||
// A level change (indent/outdent) means nothing for a list-style level.
|
||||
if p.level == l {
|
||||
lvl.para = p.props;
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply body (text box) changes to the edited or selected shapes.
|
||||
pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, opt_usize_param, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -44,9 +44,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn slide_indices(s: &Session, p: &Value) -> Result<Vec<usize>> {
|
||||
fn slide_indices(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let st = s.doc()?;
|
||||
if let Some(i) = usize_param(p, "index") {
|
||||
if let Some(i) = opt_usize_param(p, "index", cmd)? {
|
||||
return Ok(vec![i]);
|
||||
}
|
||||
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
|
||||
@@ -59,7 +59,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?;
|
||||
let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default();
|
||||
let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32);
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.set")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -83,7 +83,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string();
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.options")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i)
|
||||
@@ -98,7 +98,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.timing")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -127,7 +127,7 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it.
|
||||
fn sound(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.sound")?;
|
||||
let looping = bool_param(p, "loop");
|
||||
let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| {
|
||||
s.edit(|doc, _| {
|
||||
@@ -215,3 +215,29 @@ fn apply_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("transition.set", json!({"kind": "fade", "index": bad})),
|
||||
("transition.options", json!({"option": "x", "index": bad})),
|
||||
("transition.timing", json!({"duration": 500, "index": bad})),
|
||||
("transition.sound", json!({"none": true, "index": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert!(s.doc().expect("doc").doc.slides.iter().all(|sl| sl.transition.is_none()), "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+331
-36
@@ -7,6 +7,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -92,6 +93,12 @@ pub struct Change {
|
||||
pub kind: ChangeKind,
|
||||
/// Index in the open deck (for inserted: where it would go).
|
||||
pub slide: usize,
|
||||
/// The editor's number of that slide (`slide` + 1).
|
||||
pub number: usize,
|
||||
/// Which side has it alone: `deck` (the open presentation; accepting deletes it) or `file`
|
||||
/// (the compared file; accepting adds it). Absent when both have it and differ.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub only_in: Option<&'static str>,
|
||||
/// Index in the revised deck (none for deleted).
|
||||
pub revised: Option<usize>,
|
||||
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
|
||||
@@ -114,23 +121,46 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
|
||||
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
|
||||
let mut out = vec![];
|
||||
let mut media = Media::new(cur, rev);
|
||||
for (i, j) in &pairs {
|
||||
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
|
||||
let details = slide_details(cur, a, rev, b);
|
||||
let details = slide_details(cur, a, rev, b, &mut media);
|
||||
if !details.is_empty() {
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
|
||||
}
|
||||
}
|
||||
for (i, s) in cur.slides.iter().enumerate() {
|
||||
if !cur_matched.contains(&i) {
|
||||
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Deleted,
|
||||
slide: i,
|
||||
number: i.saturating_add(1),
|
||||
only_in: Some("deck"),
|
||||
revised: None,
|
||||
details: vec![slide_title(s)],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Each revised slide's open match (the first pair naming it).
|
||||
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
|
||||
for p in &pairs {
|
||||
rev_to_cur.entry(p.1).or_insert(p.0);
|
||||
}
|
||||
// After the open slide that matches the nearest matched revised slide before it.
|
||||
let mut at = 0;
|
||||
for (j, s) in rev.slides.iter().enumerate() {
|
||||
if let Some(i) = rev_to_cur.get(&j) {
|
||||
at = i + 1;
|
||||
}
|
||||
if !rev_matched.contains(&j) {
|
||||
// After the open slide that matches the revised slide before it.
|
||||
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
|
||||
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Inserted,
|
||||
slide: at,
|
||||
number: at.saturating_add(1),
|
||||
only_in: Some("file"),
|
||||
revised: Some(j),
|
||||
details: vec![slide_title(s)],
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
|
||||
@@ -153,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
|
||||
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
|
||||
}
|
||||
|
||||
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
|
||||
p.media(id).map(|m| m.data.as_slice())
|
||||
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
|
||||
/// show one picture many times, and its bytes are compared once per pair.
|
||||
struct Media<'a> {
|
||||
cur: HashMap<MediaId, &'a [u8]>,
|
||||
rev: HashMap<MediaId, &'a [u8]>,
|
||||
same: HashMap<(MediaId, MediaId), bool>,
|
||||
}
|
||||
|
||||
impl<'a> Media<'a> {
|
||||
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
|
||||
// The first item with an id wins, as in `Presentation::media`.
|
||||
let index = |p: &'a Presentation| {
|
||||
let mut m = HashMap::new();
|
||||
for x in &p.media {
|
||||
m.entry(x.id).or_insert(x.data.as_slice());
|
||||
}
|
||||
m
|
||||
};
|
||||
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
|
||||
}
|
||||
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
|
||||
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
|
||||
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
|
||||
_ => ba.is_some() || bb.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape without its ids (shape and media), for comparing across decks.
|
||||
@@ -172,7 +228,18 @@ fn plain_shape(s: &Shape) -> Shape {
|
||||
c
|
||||
}
|
||||
|
||||
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
|
||||
/// The box a shape is drawn with: its own, else the one it inherits from its layout or master
|
||||
/// placeholder (`ctx`).
|
||||
fn drawn_box(ctx: Option<&Ctx>, s: &Shape) -> Option<deckcraft_model::Xfrm> {
|
||||
s.xfrm.or_else(|| ctx.map(|c| deckcraft_model::resolve::xfrm(c, s)))
|
||||
}
|
||||
|
||||
/// The rotation and flips a shape is drawn with (none without a box).
|
||||
fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
|
||||
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
|
||||
}
|
||||
|
||||
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
|
||||
let label = shape_label(b);
|
||||
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
|
||||
let mut what = vec![];
|
||||
@@ -181,61 +248,92 @@ fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out
|
||||
} else if a.text != b.text {
|
||||
what.push("text formatting");
|
||||
}
|
||||
match (&a.xfrm, &b.xfrm) {
|
||||
// Boxes as drawn: a stored box equal to the inherited one is no change.
|
||||
match (&drawn_box(ca, a), &drawn_box(cb, b)) {
|
||||
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
|
||||
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
|
||||
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
|
||||
_ => {}
|
||||
}
|
||||
// Rotation and flips are reported alongside a move or resize, not hidden behind it. No stored
|
||||
// box means the inherited one (the layout's or master's), as the shape is drawn.
|
||||
let (ta, tb) = (turn(ca, a), turn(cb, b));
|
||||
if ta.0 != tb.0 {
|
||||
what.push("rotated");
|
||||
}
|
||||
if (ta.1, ta.2) != (tb.1, tb.2) {
|
||||
what.push("flipped");
|
||||
}
|
||||
if a.geom != b.geom {
|
||||
what.push("shape");
|
||||
}
|
||||
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
|
||||
what.push("formatting");
|
||||
}
|
||||
let pic = |p: &Presentation, s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
|
||||
let pic = |s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => Some(fill.media),
|
||||
_ => None,
|
||||
};
|
||||
if pic(cp, a) != pic(rp, b) {
|
||||
if media.differ(pic(a), pic(b)) {
|
||||
what.push("picture");
|
||||
}
|
||||
if what.is_empty() {
|
||||
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
|
||||
x.text = None;
|
||||
y.text = None;
|
||||
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
|
||||
x.kind = ShapeKind::Shape;
|
||||
y.kind = ShapeKind::Shape;
|
||||
}
|
||||
if x != y {
|
||||
what.push("properties");
|
||||
}
|
||||
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
|
||||
// beside them: the fields they compared are left out of this comparison.
|
||||
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
|
||||
for z in [&mut x, &mut y] {
|
||||
z.text = None;
|
||||
z.xfrm = None;
|
||||
z.geom = Default::default();
|
||||
z.fill = None;
|
||||
z.line = None;
|
||||
z.style = None;
|
||||
z.effects = Default::default();
|
||||
}
|
||||
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
|
||||
x.kind = ShapeKind::Shape;
|
||||
y.kind = ShapeKind::Shape;
|
||||
}
|
||||
if x != y {
|
||||
what.push("properties");
|
||||
}
|
||||
if !what.is_empty() {
|
||||
out.push(format!("{label}: {}", what.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
|
||||
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
|
||||
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
|
||||
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
|
||||
for (k, x) in b.shapes.iter().enumerate() {
|
||||
by_id.entry(x.id).or_insert(k);
|
||||
if !x.name.is_empty() {
|
||||
by_name.entry(x.name.as_str()).or_default().0.push(k);
|
||||
}
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
let k = b
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|x| x.id == sa.id)
|
||||
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
|
||||
let k = by_id.get(&sa.id).copied().or_else(|| {
|
||||
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
|
||||
while ks.get(*next).is_some_and(|k| used.contains(k)) {
|
||||
*next += 1;
|
||||
}
|
||||
ks.get(*next).copied()
|
||||
});
|
||||
match k {
|
||||
Some(k) if !used.contains(&k) => {
|
||||
used.insert(k);
|
||||
if let Some(sb) = b.shapes.get(k) {
|
||||
shape_details(cp, sa, rp, sb, &mut out);
|
||||
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
|
||||
}
|
||||
}
|
||||
_ => out.push(format!("{} deleted", shape_label(sa))),
|
||||
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
|
||||
}
|
||||
}
|
||||
for (k, sb) in b.shapes.iter().enumerate() {
|
||||
if !used.contains(&k) {
|
||||
out.push(format!("{} inserted", shape_label(sb)));
|
||||
out.push(format!("{} inserted (only in the file)", shape_label(sb)));
|
||||
}
|
||||
}
|
||||
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
@@ -282,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let mut sl = slide.clone();
|
||||
// Media.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
|
||||
// looked up once per reference instead of searched.
|
||||
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in rev.media.iter().enumerate() {
|
||||
rev_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in doc.media.iter().enumerate() {
|
||||
doc_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
for id in crate::media_refs::slide_media(&sl) {
|
||||
let Some(m) = rev.media(id) else { continue };
|
||||
match doc.media(id) {
|
||||
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
|
||||
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
|
||||
Some(x) if x.data == m.data => {}
|
||||
None => {
|
||||
// Keeps the revision's id, so later ids must start past it.
|
||||
doc_at.insert(id, doc.media.len());
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(id.0);
|
||||
}
|
||||
Some(_) => {
|
||||
let before = doc.media.len();
|
||||
let new = doc
|
||||
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
|
||||
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
|
||||
if doc.media.len() > before {
|
||||
doc_at.entry(new).or_insert(before);
|
||||
}
|
||||
media_map.insert(id, new);
|
||||
}
|
||||
}
|
||||
@@ -354,6 +467,25 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
|
||||
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
|
||||
}
|
||||
// A placeholder without a box of its own is drawn with its layout's; where this deck's layout
|
||||
// draws it otherwise (placed or turned differently), the file's box is stored on it, so the
|
||||
// accepted slide looks as the file does and compares equal to it.
|
||||
if let (Some(file), Some(here)) = (Ctx::for_slide(rev, slide), Ctx::for_slide(doc, &sl)) {
|
||||
let boxes: Vec<Option<deckcraft_model::Xfrm>> = slide
|
||||
.shapes
|
||||
.iter()
|
||||
.zip(&sl.shapes)
|
||||
.map(|(f, h)| {
|
||||
let want = deckcraft_model::resolve::xfrm(&file, f);
|
||||
(h.xfrm.is_none() && deckcraft_model::resolve::xfrm(&here, h) != want).then_some(want)
|
||||
})
|
||||
.collect();
|
||||
for (h, b) in sl.shapes.iter_mut().zip(boxes) {
|
||||
if b.is_some() {
|
||||
h.xfrm = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
match replacing {
|
||||
Some(id) => sl.id = id,
|
||||
None => {
|
||||
@@ -448,6 +580,39 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
|
||||
|
||||
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
|
||||
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
|
||||
let mut cur = defaults::new_presentation(None);
|
||||
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
|
||||
let mut rev = cur.clone();
|
||||
let pic = |id: u32| Shape {
|
||||
id: ShapeId(id),
|
||||
name: "Box".into(),
|
||||
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
|
||||
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
|
||||
let mut base = (*rev.slides[0]).clone();
|
||||
base.shapes.clear();
|
||||
for k in 0..3000 {
|
||||
let mut s = base.clone();
|
||||
s.id = SlideId(300_000 + k);
|
||||
rev.slides.push(Arc::new(s));
|
||||
}
|
||||
let changes = super::diff(&cur, &rev, false);
|
||||
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
|
||||
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
|
||||
}
|
||||
|
||||
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
|
||||
/// transition sound. `tag` starts the bytes of each.
|
||||
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
|
||||
@@ -494,6 +659,136 @@ mod tests {
|
||||
assert_eq!(bytes(back.media), b"open background");
|
||||
}
|
||||
|
||||
/// NN6: a slide reset drops the placeholders' stored boxes; comparing it with the copy from
|
||||
/// before the reset must not call that a rotation (no stored box is rotation 0).
|
||||
#[test]
|
||||
fn a_reset_slide_is_not_rotated_against_its_earlier_copy() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "b"})).unwrap();
|
||||
let ids: Vec<u32> = s.doc().unwrap().doc.slides[1].shapes.iter().map(|x| x.id.0).collect();
|
||||
for id in &ids {
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": -10, "dy": 0})).unwrap();
|
||||
}
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
|
||||
assert!(!v.to_string().contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// A shape both moved and rotated reports both, not only the move.
|
||||
#[test]
|
||||
fn a_moved_and_rotated_shape_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": 40, "dy": 0})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": 30})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("moved") && v.contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// A property no other check covers (here the alt text) is reported beside a flip.
|
||||
#[test]
|
||||
fn a_flipped_shape_with_new_alt_text_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
sl.shapes.iter_mut().find(|x| x.id.0 as u64 == id).unwrap().descr = "A red box".into();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("flipped") && v.contains("properties"), "{v}");
|
||||
}
|
||||
|
||||
/// A flip and a change of shape (rectangle to ellipse) are both reported.
|
||||
#[test]
|
||||
fn a_flipped_shape_that_changed_shape_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("shape.change", &json!({"ids": [id], "preset": "ellipse"})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("flipped") && v.contains("shape\""), "{v}");
|
||||
}
|
||||
|
||||
/// A placeholder with no box of its own is drawn with its layout's rotation; setting it to 0
|
||||
/// on the slide is a rotation change, and compare says so.
|
||||
#[test]
|
||||
fn an_inherited_rotation_counts_when_compared() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let layout = doc.slides[1].layout;
|
||||
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
|
||||
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
|
||||
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
let mut x = ph.xfrm.unwrap();
|
||||
x.rot = 30.0;
|
||||
ph.xfrm = Some(x);
|
||||
assert!(doc.slides[1].shapes.iter().find(|x| title(x)).unwrap().xfrm.is_none(), "the title inherits its box");
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[1]);
|
||||
let t = sl.shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
x.rot = 0.0;
|
||||
t.xfrm = Some(x);
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
|
||||
assert!(v.to_string().contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// The file's placeholder inherits an unturned box from its layout while this deck's layout
|
||||
/// turns it: accepting stores the file's box on the placeholder, so the slide is drawn as in the
|
||||
/// file and nothing is left to accept.
|
||||
#[test]
|
||||
fn accepting_an_inherited_box_takes_the_files_box() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let file = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let layout = doc.slides[1].layout;
|
||||
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
|
||||
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
let mut x = ph.xfrm.unwrap();
|
||||
x.rot = 30.0;
|
||||
ph.xfrm = Some(x);
|
||||
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
|
||||
assert!(v.to_string().contains("rotated"), "{v}");
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.doc.slides[1].shapes.iter().find(|x| title(x)).unwrap();
|
||||
assert_eq!(t.xfrm.map(|x| x.rot), Some(0.0), "the file's box is stored: {:?}", t.xfrm);
|
||||
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
|
||||
assert!(v["changes"].as_array().is_some_and(|c| c.is_empty()), "nothing left to accept: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_change_says_which_side_it_is_on() {
|
||||
let (mut s, rev, _) = decks();
|
||||
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
for c in v["changes"].as_array().unwrap() {
|
||||
assert_eq!(c["number"].as_u64().unwrap(), c["slide"].as_u64().unwrap() + 1, "{c}");
|
||||
match c["kind"].as_str().unwrap() {
|
||||
"deleted" => assert_eq!(c["onlyIn"], "deck", "{c}"),
|
||||
"inserted" => assert_eq!(c["onlyIn"], "file", "{c}"),
|
||||
_ => assert!(c.get("onlyIn").is_none(), "{c}"),
|
||||
}
|
||||
}
|
||||
// A shape only in the open deck reads as only in the deck.
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
let v = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert!(v.to_string().contains("deleted (only in the deck)"), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
|
||||
let (mut s, rev, n) = decks();
|
||||
|
||||
@@ -35,8 +35,8 @@ pub enum Access {
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
read: Vec<Root>,
|
||||
write: Vec<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -62,46 +62,47 @@ impl Root {
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// The read and write roots, which must be existing folders; `None` when both are empty
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
let read = read.iter().map(|p| Root::open(p, Access::Read)).collect::<Result<_, _>>()?;
|
||||
let write = write.iter().map(|p| Root::open(p, Access::Write)).collect::<Result<_, _>>()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
/// The read roots, absolute with their links followed.
|
||||
pub fn read_roots(&self) -> impl Iterator<Item = &Path> {
|
||||
self.read.iter().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
/// The write roots, absolute with their links followed.
|
||||
pub fn write_roots(&self) -> impl Iterator<Item = &Path> {
|
||||
self.write.iter().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
if roots.is_empty() {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
if roots.iter().any(|root| inside(&resolved, &root.resolved)) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +277,7 @@ mod tests {
|
||||
}
|
||||
std::fs::write(base.join("in/a.deckcraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
let roots = AutomationRoots::new(&[base.join("in")], &[base.join("out")]).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
@@ -300,6 +301,17 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_configured_root_grants_access() {
|
||||
let (base, _) = folders("multiple");
|
||||
let roots = AutomationRoots::new(&[base.join("in"), base.join("in2")], &[base.join("out"), base.join("outside")]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("out/new.deckcraft")), Access::Write), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("outside/new.deckcraft")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
@@ -336,18 +348,18 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
let e = AutomationRoots::new(&[base.join("nope")], &[]).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err();
|
||||
let e = AutomationRoots::new(&[], &[base.join("in/a.deckcraft")]).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
assert!(AutomationRoots::new(&[PathBuf::new()], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let read_only = AutomationRoots::new(&[base.join("in")], &[]).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
@@ -384,7 +396,7 @@ mod tests {
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
let linked = AutomationRoots::new(&[base.join("in-link")], &[]).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ pub fn install_embedded_fonts(p: &Presentation) {
|
||||
}
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive). Names that
|
||||
/// start with a dot are macOS hidden system fonts (`.SF NS`); they never appear in a font list
|
||||
/// and can't be installed, so they aren't reported.
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command —
|
||||
//! Last-resort guard: a panic that escapes a command —
|
||||
//! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an
|
||||
//! error, and the document is kept as it was before the command (and saved for crash recovery
|
||||
//! when a recovery directory is set). It is a safety net: commands still must not panic.
|
||||
|
||||
+31
-2
@@ -41,12 +41,15 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => std::path::PathBuf::from(path),
|
||||
};
|
||||
let target_str = target_path.to_str().unwrap_or(path);
|
||||
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
|
||||
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
// A short fixed-size name: appending to the target's name could exceed the file name limit.
|
||||
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let tmp = target_path.with_file_name(tmp_name);
|
||||
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
|
||||
crate::file_access::check_write(&tmp.to_string_lossy())?;
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
@@ -120,4 +123,30 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
let _ = std::fs::remove_dir(&tmp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_overwrites_file_with_name_near_the_length_limit() {
|
||||
let dir = std::env::temp_dir().join("deckcraft_io_long_name_test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let file = dir.join(format!("{}.pptx", "a".repeat(250)));
|
||||
std::fs::write(&file, b"old").unwrap();
|
||||
let path_str = file.to_str().unwrap();
|
||||
|
||||
write_file(path_str, b"new").unwrap();
|
||||
assert_eq!(read_file(path_str).unwrap(), b"new");
|
||||
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
|
||||
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
|
||||
let root = base.join("root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
|
||||
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
|
||||
assert!(res.unwrap_err().contains("outside the write root"));
|
||||
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,7 +682,28 @@ impl Session {
|
||||
self.journal.drain(..1000);
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
Ok(if outer { self.with_editing(id, r) } else { r })
|
||||
}
|
||||
|
||||
/// In Slide Master / layout (or handout / notes master) view the insert commands edit the
|
||||
/// master or layout, not a slide; their answer says so with `editing: {kind, name}`, as
|
||||
/// `slide.inspect` does. Nothing is added on slides, so those answers stay as they were.
|
||||
fn with_editing(&self, id: &str, mut r: Value) -> Value {
|
||||
let inserts = id.starts_with("insert.") || matches!(id, "shape.insert" | "shape.freeform" | "shape.connect" | "slide.new");
|
||||
let (true, Some(st), Some(o)) = (inserts, self.active(), r.as_object_mut()) else { return r };
|
||||
let doc = &st.doc;
|
||||
let edited = match st.selection.target {
|
||||
Target::Master { master } => doc.masters.get(master).map(|m| ("master", m.name.clone())),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| ("layout", l.name.clone())),
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
page_master(doc, t).map(|m| (if matches!(t, Target::Handout) { "handout" } else { "notesMaster" }, m.name.clone()))
|
||||
}
|
||||
Target::Slides => None,
|
||||
};
|
||||
if let Some((kind, name)) = edited {
|
||||
o.entry("editing").or_insert_with(|| serde_json::json!({"kind": kind, "name": name}));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
@@ -695,6 +716,7 @@ impl Session {
|
||||
let mut doc = (*st.doc).clone();
|
||||
let mut sel = st.selection.clone();
|
||||
let r = f(&mut doc, &mut sel)?;
|
||||
cmd::arrange::refit_groups(&st.doc, &mut doc);
|
||||
connect::reroute(&mut doc, &sel);
|
||||
st.doc = Arc::new(doc);
|
||||
st.selection = sel;
|
||||
|
||||
@@ -259,6 +259,16 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identity_is_stable_nonzero_and_json_safe() {
|
||||
let id = instance_id();
|
||||
assert_eq!(instance_id(), id);
|
||||
assert!(id != 0 && id <= UID_MASK);
|
||||
assert_eq!(id & 0xFF_FFFF, 0);
|
||||
let encoded = serde_json::to_value(id).unwrap();
|
||||
assert_eq!(encoded.as_f64().unwrap() as u64, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_unsaved_presentations() {
|
||||
let dir = temp("recovery");
|
||||
|
||||
+107
-14
@@ -356,7 +356,14 @@ impl Dictionary {
|
||||
}
|
||||
|
||||
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
|
||||
/// Words longer than 100 characters are not checked (they pass).
|
||||
pub fn check(&self, word: &str) -> bool {
|
||||
// Compound splitting tries every split point, so checking costs O(n²) in the word's
|
||||
// length: a 100,000-character "word" took seconds per check under a compounding
|
||||
// dictionary, on every edit (#1175).
|
||||
if word.chars().nth(MAX_CHECK_CHARS).is_some() {
|
||||
return true;
|
||||
}
|
||||
if word.contains('\u{2019}') {
|
||||
return self.check(&word.replace('\u{2019}', "'"));
|
||||
}
|
||||
@@ -394,12 +401,18 @@ impl Dictionary {
|
||||
return vec![];
|
||||
}
|
||||
let cap = word.chars().next().is_some_and(char::is_uppercase);
|
||||
let lower = if cap && word.chars().skip(1).all(|c| !c.is_uppercase()) { word.to_lowercase() } else { word.to_string() };
|
||||
let upper = word.chars().count() > 1 && word.chars().any(char::is_uppercase) && !word.chars().any(char::is_lowercase);
|
||||
let lower = if upper || (cap && word.chars().skip(1).all(|c| !c.is_uppercase())) { word.to_lowercase() } else { word.to_string() };
|
||||
let w: Vec<char> = lower.chars().collect();
|
||||
let mut out: Vec<String> = vec![];
|
||||
let mut seen = HashSet::new();
|
||||
let mut push = |s: String, out: &mut Vec<String>| {
|
||||
if s != lower && seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) {
|
||||
if s == lower {
|
||||
return;
|
||||
}
|
||||
// Checked in capitals, so a KEEPCASE entry ("NASA") is offered for an all-caps word.
|
||||
let s = if upper { s.to_uppercase() } else { s };
|
||||
if seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) {
|
||||
out.push(s);
|
||||
}
|
||||
};
|
||||
@@ -443,7 +456,7 @@ impl Dictionary {
|
||||
push(format!("{} {}", s(&w[..i]), s(&w[i..])), &mut out);
|
||||
}
|
||||
out.truncate(n);
|
||||
if cap {
|
||||
if cap && !upper {
|
||||
out = out.into_iter().map(|x| capitalise(&x)).collect();
|
||||
}
|
||||
out
|
||||
@@ -553,6 +566,9 @@ pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> {
|
||||
/// Longest word `Dictionary::suggest` generates corrections for.
|
||||
const MAX_SUGGEST_CHARS: usize = 64;
|
||||
|
||||
/// Longest word `Dictionary::check` checks, as Hunspell's word length limit.
|
||||
const MAX_CHECK_CHARS: usize = 100;
|
||||
|
||||
type Cache = Mutex<HashMap<String, Option<Arc<Dictionary>>>>;
|
||||
|
||||
fn cache() -> &'static Cache {
|
||||
@@ -684,16 +700,31 @@ pub struct Word {
|
||||
pub repeated: Option<usize>,
|
||||
}
|
||||
|
||||
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
|
||||
/// value from a file is no language we have a dictionary for, and copying it into every word of
|
||||
/// a paragraph would multiply it.
|
||||
const MAX_LANG_LEN: usize = 64;
|
||||
|
||||
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
|
||||
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
|
||||
/// part by part.
|
||||
/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
|
||||
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
|
||||
let mut chars: Vec<(char, usize)> = vec![];
|
||||
for (ri, r) in p.runs.iter().enumerate() {
|
||||
chars.extend(r.content().chars().map(|c| (c, ri)));
|
||||
}
|
||||
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
|
||||
let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
||||
let default = if default.len() > MAX_LANG_LEN { "" } else { default };
|
||||
let lang_of = |ri: usize| {
|
||||
p.runs
|
||||
.get(ri)
|
||||
.and_then(|r| r.props.lang.as_deref())
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
|
||||
.unwrap_or(default)
|
||||
.to_string()
|
||||
};
|
||||
// Combining marks (decomposed accents) belong to the letter before them.
|
||||
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
@@ -709,8 +740,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
|
||||
while a < b && !chars[a].0.is_alphanumeric() {
|
||||
a += 1;
|
||||
}
|
||||
while b > a && !chars[b - 1].0.is_alphanumeric() {
|
||||
b -= 1;
|
||||
// Trim back to the last letter or digit, keeping only the marks that sit on it
|
||||
// (a mark on a trailing apostrophe goes with the apostrophe).
|
||||
while b > a {
|
||||
let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a);
|
||||
if chars[k].0.is_alphanumeric() {
|
||||
break;
|
||||
}
|
||||
b = k;
|
||||
}
|
||||
let text: String = chars[a..b].iter().map(|c| c.0).collect();
|
||||
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
|
||||
@@ -768,15 +805,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
|
||||
.chain(repeated(p, default))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.fold(vec![], |mut v: Vec<Word>, w| {
|
||||
.fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
|
||||
// A repeated misspelt word is reported once, as repeated.
|
||||
if let Some(i) = v.iter().position(|x| x.start == w.start) {
|
||||
v[i] = w;
|
||||
} else {
|
||||
v.push(w);
|
||||
match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
|
||||
Some(x) => *x = w,
|
||||
None => {
|
||||
at.insert(w.start, v.len());
|
||||
v.push(w);
|
||||
}
|
||||
}
|
||||
v
|
||||
(v, at)
|
||||
})
|
||||
.0
|
||||
}
|
||||
|
||||
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
|
||||
@@ -803,6 +843,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nfd_word_not_split() {
|
||||
let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive");
|
||||
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
|
||||
assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_on_trailing_punctuation_is_trimmed_with_it() {
|
||||
let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x");
|
||||
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
|
||||
assert_eq!(w, ["word", "cafe\u{301}", "x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curly_apostrophe_matches_ascii_entry() {
|
||||
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
|
||||
@@ -843,6 +897,28 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
|
||||
}
|
||||
|
||||
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
|
||||
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
|
||||
#[test]
|
||||
fn a_word_longer_than_the_check_limit_is_not_checked() {
|
||||
let d = Dictionary::parse(b"COMPOUNDFLAG X\nCOMPOUNDMIN 3\n", b"2\nfoot/X\nball/X\n".as_slice());
|
||||
assert!(d.check("footballfoot"), "a compound is checked");
|
||||
let under: String = "footb".repeat(MAX_CHECK_CHARS / 5);
|
||||
assert_eq!(under.chars().count(), MAX_CHECK_CHARS);
|
||||
assert!(!d.check(&under), "a word at the limit is still checked");
|
||||
// Unchecked, not seconds of compound splitting.
|
||||
assert!(d.check(&"footb".repeat(20_000)));
|
||||
assert!(d.check(&format!("{under}x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_caps_words_get_uppercase_suggestions() {
|
||||
let d = d();
|
||||
assert!(d.suggest("SLDE", 5).contains(&"SLIDE".to_string()));
|
||||
assert!(d.suggest("FOTO", 5).contains(&"PHOTO".to_string()));
|
||||
assert!(d.suggest("NSSA", 5).contains(&"NASA".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compounds_join_flagged_parts() {
|
||||
let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n";
|
||||
@@ -894,6 +970,23 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert_eq!(w, vec!["check", "done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
|
||||
use deckcraft_model::{Paragraph, Run, RunProps};
|
||||
// 100,000 copies of one word under a 1 MB language value: every word after the first is
|
||||
// a repeat, and no word carries a copy of the value.
|
||||
let lang = "x".repeat(1 << 20);
|
||||
let p =
|
||||
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
|
||||
let words = paragraph_words(&p, "en-US");
|
||||
assert_eq!(words.len(), 100_000);
|
||||
assert!(words.iter().all(|w| w.lang.is_empty()));
|
||||
let bad = misspelt(&p, "en-US", &[]);
|
||||
assert_eq!(bad.len(), 99_999);
|
||||
assert!(bad.iter().all(|w| w.repeated.is_some()));
|
||||
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_and_numeric_flags_and_latin1() {
|
||||
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
||||
|
||||
+861
-1
@@ -237,6 +237,132 @@ fn pasted_shapes_get_the_next_automatic_name() {
|
||||
assert_eq!(names(&s).last().unwrap(), "Hero");
|
||||
}
|
||||
|
||||
/// PowerPoint 16.0 (Windows): on "Rectangle 1, Oval 2, TextBox 3, Hero" Duplicate gives
|
||||
/// "Rectangle 5" and then "Rectangle 6", "TextBox 7"; the copy of "Hero" (and of its copy) is
|
||||
/// "Hero". Never "Hero copy".
|
||||
#[test]
|
||||
fn duplicate_gives_the_next_automatic_name_and_keeps_a_chosen_one() {
|
||||
let mut s = session();
|
||||
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
let add = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
s.doc().unwrap().selection.shapes[0].0
|
||||
};
|
||||
let rect = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [10, 10, 50, 50]}));
|
||||
let tb = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [100, 10, 50, 50]}));
|
||||
s.execute("shape.rename", &json!({"id": tb, "name": "Hero"})).unwrap();
|
||||
let n = names(&s).len();
|
||||
let auto = names(&s)[n - 2].clone();
|
||||
let (base, _) = auto.rsplit_once(' ').unwrap();
|
||||
|
||||
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
|
||||
let all = names(&s);
|
||||
assert_eq!(all[n], format!("{base} {}", n + 1), "{all:?}");
|
||||
assert_eq!(all[n + 1], format!("{base} {}", n + 2), "{all:?}");
|
||||
|
||||
s.execute("edit.duplicate", &json!({"ids": [tb]})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("edit.duplicate", &json!({"ids": [copy]})).unwrap();
|
||||
let all = names(&s);
|
||||
assert_eq!(&all[n + 2..], ["Hero", "Hero"], "{all:?}");
|
||||
}
|
||||
|
||||
/// Two shapes sharing a chosen name are still two shapes: the next number follows the count.
|
||||
#[test]
|
||||
fn duplicate_numbers_from_the_shape_count_when_names_repeat() {
|
||||
let mut s = session();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
|
||||
let rect = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 10, 50, 50]})).unwrap();
|
||||
let hero = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("shape.rename", &json!({"id": hero, "name": "Hero"})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({"ids": [hero]})).unwrap();
|
||||
let before = s.doc().unwrap().current_slide().unwrap().shapes.len();
|
||||
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let shapes = &doc.current_slide().unwrap().shapes;
|
||||
let source = shapes.iter().find(|x| x.id.0 == rect).unwrap();
|
||||
let base = source.name.rsplit_once(' ').unwrap().0;
|
||||
let names: Vec<_> = shapes.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(shapes[before].name, format!("{base} {}", before + 1), "{names:?}");
|
||||
}
|
||||
|
||||
/// The next automatic name looks at the names inside groups too.
|
||||
#[test]
|
||||
fn duplicating_a_group_does_not_repeat_the_names_of_its_members() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for x in [10, 70, 130, 190] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("arrange.group", &json!({"ids": [ids[2], ids[3]]})).unwrap();
|
||||
let group = s.doc().unwrap().selection.shapes[0].0;
|
||||
// Fewer top-level shapes than names in use: the next number is one a member already has.
|
||||
s.execute("edit.delete", &json!({"ids": [ids[0], ids[1]]})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let mut all = vec![];
|
||||
deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone()));
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
/// A shape inserted or pasted after a group was duplicated takes none of its members' names.
|
||||
#[test]
|
||||
fn inserting_after_duplicating_a_group_does_not_repeat_a_member_name() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for x in [10, 70] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("arrange.group", &json!({"ids": ids})).unwrap();
|
||||
let group = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
|
||||
// Fewer top-level shapes than shapes: counting only the top level reaches a member's number.
|
||||
for x in [130, 190, 250] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let mut all = vec![];
|
||||
deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone()));
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
/// Group members are renamed like any other copied shape.
|
||||
#[test]
|
||||
fn duplicating_a_group_renames_its_automatic_children() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for x in [10, 100] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("arrange.group", &json!({"ids": ids})).unwrap();
|
||||
let group = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let shapes = &doc.current_slide().unwrap().shapes;
|
||||
let (orig, copy) = (&shapes[shapes.len() - 2], &shapes[shapes.len() - 1]);
|
||||
assert!(orig.is_group() && copy.is_group());
|
||||
assert_ne!(orig.name, copy.name);
|
||||
assert!(copy.name.starts_with("Group "), "{}", copy.name);
|
||||
let all: Vec<_> = orig.children().iter().chain(copy.children()).map(|c| c.name.as_str()).collect();
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_after_a_deleted_shape_does_not_repeat_a_name() {
|
||||
let mut s = session();
|
||||
@@ -1425,6 +1551,14 @@ fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macos_hidden_system_font_is_not_reported_missing() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font(".SF NS")})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!([]));
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_face_is_used_when_the_machine_lacks_it() {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
@@ -2276,6 +2410,23 @@ fn pdf_options_take_paper_and_orientation() {
|
||||
assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err());
|
||||
}
|
||||
|
||||
/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page.
|
||||
#[test]
|
||||
fn pdf_options_reject_unsupported_per_page() {
|
||||
for n in [1, 2, 3, 4, 6, 9] {
|
||||
let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap();
|
||||
assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n));
|
||||
}
|
||||
for n in [0, 5, 7, 8, 10] {
|
||||
let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err();
|
||||
assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}");
|
||||
}
|
||||
// Not a count at all: refused too, instead of falling back to 6.
|
||||
for v in [json!(-1), json!(5.5), json!("6")] {
|
||||
assert!(cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": v}), 1).is_err(), "perPage {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_line_sets_compound_and_arrow_sizes() {
|
||||
// The Format Shape pane's Line page: compound type, arrowheads and their sizes.
|
||||
@@ -2320,6 +2471,15 @@ fn shape_effects_props_adjust_shadow_glow_and_reflection() {
|
||||
let f = fx(&s);
|
||||
assert!(f.outer_shadow.is_none());
|
||||
assert_eq!(f.inner_shadow.unwrap().blur, 3.0);
|
||||
// Size scales both axes (PowerPoint's 0–200%); out-of-range values clamp.
|
||||
s.execute("shape.effects", &json!({"shadow": "inner"})).unwrap();
|
||||
s.execute("shape.effects", &json!({"shadowProps": {"size": 1.5}})).unwrap();
|
||||
let sw = fx(&s).inner_shadow.unwrap();
|
||||
assert_eq!((sw.sx, sw.sy), (1.5, 1.5));
|
||||
s.execute("shape.effects", &json!({"shadowProps": {"size": 9}})).unwrap();
|
||||
assert_eq!(fx(&s).inner_shadow.unwrap().sx, 2.0);
|
||||
s.execute("shape.effects", &json!({"shadowProps": {"size": 0}})).unwrap();
|
||||
assert_eq!(fx(&s).inner_shadow.unwrap().sy, 0.01);
|
||||
// Glow: size, colour, transparency; size 0 removes it.
|
||||
s.execute("shape.effects", &json!({"glowProps": {"radius": 12, "transparency": 0.5}})).unwrap();
|
||||
let g = fx(&s).glow.unwrap();
|
||||
@@ -3055,6 +3215,16 @@ fn motion_path_presets_have_paths_ids_and_round_trip() {
|
||||
fn numbering_takes_color_and_size() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": "One\nTwo"})).unwrap();
|
||||
{
|
||||
let id = s.doc().unwrap().selection.shapes[0];
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let shape = deckcraft_model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut d.slides[0]).shapes, id).unwrap();
|
||||
let props = &mut shape.text.as_mut().unwrap().paragraphs[0].props;
|
||||
props.bullet_font_follow_text = Some(true);
|
||||
props.bullet_color_follow_text = Some(true);
|
||||
props.bullet_size_follow_text = Some(true);
|
||||
props.bullet_size_points = Some(12.0);
|
||||
}
|
||||
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
|
||||
let para = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
@@ -3064,9 +3234,15 @@ fn numbering_takes_color_and_size() {
|
||||
let pp = para(&s);
|
||||
assert!(matches!(&pp.bullet, Some(deckcraft_model::text::Bullet::AutoNum { scheme, start_at: 3 }) if scheme == "romanUcPeriod"));
|
||||
assert_eq!(pp.bullet_size, Some(1.5));
|
||||
assert_eq!(pp.bullet_font_follow_text, Some(true));
|
||||
assert_eq!(pp.bullet_color_follow_text, None);
|
||||
assert_eq!(pp.bullet_size_follow_text, None);
|
||||
assert_eq!(pp.bullet_size_points, None);
|
||||
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
|
||||
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
|
||||
assert_eq!(para(&s).bullet_size, Some(4.0));
|
||||
assert_eq!(para(&s).bullet_size_points, None);
|
||||
assert_eq!(para(&s).bullet_font_follow_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4138,6 +4314,38 @@ fn a_params_doc_spells_out_each_parameter() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `shape.fill`'s params doc names each key and kind its `gradient` takes.
|
||||
#[test]
|
||||
fn shape_fill_documents_each_gradient_key_and_kind() {
|
||||
use deckcraft_model::style::GradientShape;
|
||||
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
|
||||
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
|
||||
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
|
||||
for kind in ["linear", "radial", "rectangular", "path"] {
|
||||
assert!(about.contains(kind), "{kind}: {about}");
|
||||
let g = cmd::shape::gradient_param(&json!({
|
||||
"stops": [[0, "#ffffff"], [1, "#000000"]],
|
||||
"kind": kind,
|
||||
"angle": 30,
|
||||
"focus": [0, 0, 1, 1],
|
||||
"rotateWithShape": false
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!g.rotate_with_shape, "{kind}");
|
||||
match g.shape {
|
||||
GradientShape::Linear { angle, .. } => assert_eq!((kind, angle), ("linear", 30.0)),
|
||||
GradientShape::Path { path, focus } => {
|
||||
let expected = match kind {
|
||||
"radial" => "circle",
|
||||
"rectangular" => "rect",
|
||||
_ => "shape",
|
||||
};
|
||||
assert_eq!((path.as_str(), focus), (expected, [0.0, 0.0, 1.0, 1.0]), "{kind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_command_acts_on_names_only_keys_it_takes() {
|
||||
for c in command_specs() {
|
||||
@@ -4421,6 +4629,16 @@ fn autoformat_starts_bulleted_and_numbered_lists_as_you_type() {
|
||||
assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["lists"], true);
|
||||
s.execute("text.autocorrect", &json!({"lists": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "* x"), ("* x".to_string(), none));
|
||||
// List AutoFormat is independent of the Replace text as you type setting.
|
||||
s.execute("text.autocorrect", &json!({"on": false, "lists": true})).unwrap();
|
||||
assert_eq!(typed(&mut s, "*"), ("*".to_string(), Some(Bullet::None)));
|
||||
s.execute("text.insert", &json!({"text": " "})).unwrap();
|
||||
assert_eq!(para(&s), (String::new(), ch("•")));
|
||||
s.execute("text.delete", &json!({"dir": "backward"})).unwrap();
|
||||
assert_eq!(para(&s), ("* ".to_string(), Some(Bullet::None)));
|
||||
// Word replacements still run while list formatting is disabled.
|
||||
s.execute("text.autocorrect", &json!({"on": true, "lists": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "teh "), ("The ".to_string(), Some(Bullet::None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5144,7 +5362,8 @@ fn opening_a_file_caps_absurd_effect_sizes() {
|
||||
e.soft_edge = Some(f64::NAN);
|
||||
let sw = e.outer_shadow.as_mut().unwrap();
|
||||
(sw.blur, sw.dist, sw.sx) = (huge, huge, huge);
|
||||
*e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge };
|
||||
*e.reflection.as_mut().unwrap() =
|
||||
Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge, ..Default::default() };
|
||||
}
|
||||
let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
|
||||
let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind {
|
||||
@@ -5406,6 +5625,170 @@ fn edit_points_conversion_is_one_undo_step() {
|
||||
assert!(preset(&s), "the only step was the conversion");
|
||||
}
|
||||
|
||||
/// #1621: build overlapping objects with an ordinary quick style on the foreground shape.
|
||||
fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) {
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let under = s
|
||||
.execute(
|
||||
if text { "insert.textBox" } else { "shape.insert" },
|
||||
&json!({"preset": "rect", "rect": [100, 300, 300, 60], "text": if text { "UNDERLYING TEXT" } else { "" }}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let over = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 270, 350, 140]})).unwrap()["id"].as_u64().unwrap();
|
||||
(ShapeId(under as u32), ShapeId(over as u32))
|
||||
}
|
||||
|
||||
/// No Fill passes interior clicks through while preserving outline and actual-fill targets.
|
||||
#[test]
|
||||
fn no_fill_shape_allows_selecting_and_editing_underlying_text() {
|
||||
let mut s = session();
|
||||
let (under, over) = no_fill_overlap(&mut s, true);
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some(), "the ordinary quick style remains");
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(tev(kind, p.x, p.y)).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
|
||||
assert_eq!(text_sel(&s).unwrap().shape, under);
|
||||
// The transparent foreground also leaves an already-active text editor's caret alone.
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(tev(kind, p.x + 20.0, p.y)).unwrap();
|
||||
}
|
||||
assert_eq!(text_sel(&s).unwrap().shape, under);
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// The rectangle's outline is still a target, and selecting it does not remove its style.
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(tev(kind, 80.0, 330.0)).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![over]);
|
||||
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some());
|
||||
// A real fill still catches clicks, even with 100% transparency (distinct from No Fill).
|
||||
for transparency in [0.0, 1.0] {
|
||||
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#336699", "transparency": transparency})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation and pointer tolerance must preserve both pass-through interiors and outline hits.
|
||||
#[test]
|
||||
fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() {
|
||||
let mut s = session();
|
||||
let (under, over) = no_fill_overlap(&mut s, false);
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
|
||||
let x = s.doc().unwrap().shape(over).unwrap().xfrm.unwrap();
|
||||
let p = x.affine() * deckcraft_geom::Point::new(x.w / 2.0, x.h / 2.0);
|
||||
let edge = x.affine() * deckcraft_geom::Point::new(0.0, x.h / 4.0);
|
||||
for tol in [0.5, 3.0, 8.0] {
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
assert!(matches!(s.hit_test(p, tol), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||
assert!(matches!(s.hit_test(edge, tol), Some(tools::Hit::Shape { id, .. }) if id == over));
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(PointerEvent { tol, ..tev(kind, p.x, p.y) }).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Style, theme and absent fills follow rendering, with explicit fill taking precedence.
|
||||
#[test]
|
||||
fn no_fill_hit_testing_resolves_style_and_theme_fills() {
|
||||
use std::sync::Arc;
|
||||
for source in ["style", "theme", "absent"] {
|
||||
let mut s = session();
|
||||
let (under, over) = no_fill_overlap(&mut s, true);
|
||||
{
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
if source == "theme" {
|
||||
Arc::make_mut(&mut doc.masters[0]).theme.format.fills[0] = model::Fill::None;
|
||||
}
|
||||
let sh = Arc::make_mut(&mut doc.slides[st.selection.slide]).shapes.iter_mut().find(|sh| sh.id == over).unwrap();
|
||||
assert!(sh.fill.is_none(), "the shape has no explicit fill override");
|
||||
if source == "absent" {
|
||||
sh.style = None;
|
||||
} else {
|
||||
sh.style.as_mut().unwrap().fill_ref.0 = if source == "style" { 0 } else { 1 };
|
||||
}
|
||||
}
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under), "{source}");
|
||||
// An explicit fill takes precedence over the no-fill style/theme.
|
||||
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#336699"})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Text on a foreground No Fill shape retains its selection and editing target.
|
||||
#[test]
|
||||
fn no_fill_text_shapes_keep_their_interior_text_target() {
|
||||
let mut s = session();
|
||||
let (_, over) = no_fill_overlap(&mut s, true);
|
||||
s.execute("text.set", &json!({"id": over.0, "text": "FOREGROUND TEXT"})).unwrap();
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, text: true }) if id == over));
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
assert_eq!(text_sel(&s).unwrap().shape, over);
|
||||
}
|
||||
|
||||
/// A locked No Fill shape remains selectable on its outline, but dragging cannot move it.
|
||||
#[test]
|
||||
fn no_fill_locked_shape_keeps_outline_selection_without_moving() {
|
||||
let mut s = session();
|
||||
let (under, over) = no_fill_overlap(&mut s, true);
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.lock", &json!({"locked": true})).unwrap();
|
||||
let before = s.doc().unwrap().shape(over).unwrap().xfrm;
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let inside = deckcraft_geom::Point::new(200.0, 310.0);
|
||||
assert!(matches!(s.hit_test(inside, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(tev(kind, inside.x, inside.y)).unwrap();
|
||||
}
|
||||
assert_eq!(text_sel(&s).unwrap().shape, under);
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let edge = deckcraft_geom::Point::new(80.0, 330.0);
|
||||
assert!(matches!(s.hit_test(edge, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
|
||||
s.pointer(tev(PointerKind::Down, edge.x, edge.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, edge.x + 40.0, edge.y + 20.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, edge.x + 40.0, edge.y + 20.0)).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![over]);
|
||||
assert_eq!(st.shape(over).unwrap().xfrm, before);
|
||||
assert!(st.shape(over).unwrap().locked);
|
||||
}
|
||||
|
||||
/// Removing both fill and outline leaves the empty interior transparent to clicks, locked or not.
|
||||
#[test]
|
||||
fn no_fill_shape_without_text_or_outline_passes_interior_clicks() {
|
||||
for locked in [false, true] {
|
||||
let mut s = session();
|
||||
let (under, _) = no_fill_overlap(&mut s, false);
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.line", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.lock", &json!({"locked": locked})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(tev(kind, p.x, p.y)).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
|
||||
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
|
||||
#[test]
|
||||
@@ -5479,6 +5862,204 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
/// Two selected boxes with different sizes and aligned right edges, on a blank slide.
|
||||
fn resize_selection_scene() -> (Session, [u64; 2]) {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.prefs.smart_guides = false;
|
||||
s.prefs.snap_to_grid = false;
|
||||
let ids = [[100, 100, 100, 80], [120, 300, 80, 100]]
|
||||
.map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text.
|
||||
fn pending_resize_autofit(s: &mut Session, ids: &[u64]) {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).unwrap();
|
||||
for id in ids {
|
||||
let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap();
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Resizing multiple selected shapes scales them proportionally around their anchors in one undo step.
|
||||
#[test]
|
||||
fn resizing_selected_shapes_is_one_undo_step() {
|
||||
let (mut s, [a, b]) = resize_selection_scene();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
|
||||
assert_eq!(down["hit"]["kind"], "resize");
|
||||
assert_eq!(down["hit"]["id"], a);
|
||||
for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 120.0, 137.5])] {
|
||||
let actual = box_of(&mut s, id);
|
||||
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
|
||||
assert_eq!(actual[key], value, "{id}: {key}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again");
|
||||
}
|
||||
|
||||
/// Resizing selected shapes refits their text boxes inside the same undo transaction.
|
||||
#[test]
|
||||
fn resizing_selected_shapes_refits_text_in_the_same_undo_step() {
|
||||
let (mut s, ids) = resize_selection_scene();
|
||||
pending_resize_autofit(&mut s, &ids);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// An east-handle drag changes width only; a height change must come from text fitting.
|
||||
drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0));
|
||||
for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) {
|
||||
assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit");
|
||||
}
|
||||
|
||||
/// Clicking a resize handle without dragging neither modifies shapes nor triggers text autofit.
|
||||
#[test]
|
||||
fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() {
|
||||
let (mut s, ids) = resize_selection_scene();
|
||||
pending_resize_autofit(&mut s, &ids);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry");
|
||||
assert_eq!(s.doc().unwrap().revision, revision);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document");
|
||||
// Ensure this fixture would catch an accidental refit on release.
|
||||
for id in ids {
|
||||
cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap();
|
||||
}
|
||||
assert_ne!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// Locked shapes in a multi-selection are excluded from both resizing and post-resize text fitting.
|
||||
#[test]
|
||||
fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() {
|
||||
let (mut s, [a, b]) = resize_selection_scene();
|
||||
pending_resize_autofit(&mut s, &[b]);
|
||||
s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||
let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
|
||||
assert_eq!(box_of(&mut s, a)["w"], 150.0);
|
||||
assert_eq!(box_of(&mut s, a)["h"], 110.0);
|
||||
assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// Resizing selected shapes excludes other selected resize targets from smart-guide snapping.
|
||||
#[test]
|
||||
fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() {
|
||||
for reverse in [false, true] {
|
||||
let (mut s, [a, b]) = resize_selection_scene();
|
||||
s.prefs.smart_guides = true;
|
||||
let ids = if reverse { [b, a] } else { [a, b] };
|
||||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||
// Within snap tolerance of the other selected shape's original right edge.
|
||||
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
|
||||
assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back");
|
||||
assert_eq!(box_of(&mut s, b)["w"], 82.4, "selection order must not affect snapping");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resizing selected shapes snaps the active shape against unselected stationary shapes and scales others proportionally.
|
||||
#[test]
|
||||
fn resizing_selected_shapes_still_snaps_to_stationary_shapes() {
|
||||
let (mut s, [a, b]) = resize_selection_scene();
|
||||
let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap();
|
||||
let fixed_before = box_of(&mut s, fixed);
|
||||
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||
s.prefs.smart_guides = true;
|
||||
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
|
||||
assert_eq!(box_of(&mut s, a)["w"], 107.0);
|
||||
assert!((box_of(&mut s, b)["w"].as_f64().unwrap() - 85.6).abs() < 1e-9);
|
||||
assert_eq!(box_of(&mut s, fixed), fixed_before);
|
||||
}
|
||||
|
||||
/// Secondary pictures resize uniformly around their anchor, preserving independent flips
|
||||
/// and keeping the complete multi-selection resize in one undo step.
|
||||
#[test]
|
||||
fn resizing_selected_shapes_resizes_secondary_pictures() {
|
||||
// Pointer destination, Alt, expected rectangle, expected picture, expected flips.
|
||||
let cases = [
|
||||
((180.0, 200.0), false, [100.0, 100.0, 80.0, 100.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
|
||||
((200.0, 180.0), false, [100.0, 100.0, 100.0, 80.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
|
||||
((220.0, 200.0), false, [100.0, 100.0, 120.0, 100.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
|
||||
((200.0, 220.0), false, [100.0, 100.0, 100.0, 120.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
|
||||
((180.0, 200.0), true, [120.0, 100.0, 60.0, 100.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
|
||||
((200.0, 180.0), true, [100.0, 120.0, 100.0, 60.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
|
||||
((80.0, 200.0), false, [80.0, 100.0, 20.0, 100.0], [280.0, 300.0, 20.0, 20.0], [true, false]),
|
||||
((200.0, 80.0), false, [100.0, 80.0, 100.0, 20.0], [300.0, 280.0, 20.0, 20.0], [false, true]),
|
||||
];
|
||||
|
||||
for (to, alt, expected_rect, expected_picture, flips) in cases {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.prefs.smart_guides = false;
|
||||
s.prefs.snap_to_grid = false;
|
||||
|
||||
let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let picture = ramp_picture(&mut s, 1, [300, 300, 100, 100]).as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [rect, picture]})).unwrap();
|
||||
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let mods = Mods { alt, ..Default::default() };
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods, tol: 3.0, pressure: None };
|
||||
|
||||
let down = s.pointer(ev(PointerKind::Down, (200.0, 200.0))).unwrap();
|
||||
assert_eq!(down["hit"]["kind"], "resize");
|
||||
assert_eq!(down["hit"]["id"], rect);
|
||||
assert_eq!(down["hit"]["handle"], 4);
|
||||
|
||||
s.pointer(ev(PointerKind::Drag, ((200.0 + to.0) / 2.0, (200.0 + to.1) / 2.0))).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, to)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, to)).unwrap();
|
||||
|
||||
for (id, expected) in [(rect, expected_rect), (picture, expected_picture)] {
|
||||
let actual = box_of(&mut s, id);
|
||||
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
|
||||
let got = actual[key].as_f64().unwrap();
|
||||
assert!((got - value).abs() < 1e-9, "{to:?}, alt={alt}, shape={id}, {key}: expected {value}, got {got}");
|
||||
}
|
||||
|
||||
let xfrm = s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().xfrm.unwrap();
|
||||
assert_eq!([xfrm.flip_h, xfrm.flip_v], flips, "{to:?}, alt={alt}, shape={id}");
|
||||
}
|
||||
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
|
||||
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
|
||||
#[test]
|
||||
@@ -5646,3 +6227,282 @@ fn invalid_ids_array_never_falls_back_to_the_selection() {
|
||||
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_align_sets_the_horizontal_alignment() {
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
s.execute("table.cellAlign", &json!({"id": id.0, "align": "center", "cells": [[0, 0], [1, 0]]})).unwrap();
|
||||
s.execute("table.cellAlign", &json!({"id": id.0, "align": "right", "anchor": "bottom", "cells": [[0, 1]]})).unwrap();
|
||||
let t = table_of(&s, id);
|
||||
let al = |r, c| t.cell(r, c).unwrap().text.paragraphs.iter().map(|p| p.props.align).collect::<Vec<_>>();
|
||||
assert_eq!(al(0, 0), vec![Some(Align::Center)]);
|
||||
assert_eq!(al(1, 0), vec![Some(Align::Center)]);
|
||||
assert_eq!(al(0, 1), vec![Some(Align::Right)]);
|
||||
assert_eq!(al(1, 1), vec![None], "other cells are untouched");
|
||||
assert_eq!(t.cell(0, 1).unwrap().anchor, Some(Anchor::Bottom));
|
||||
assert_eq!(t.cell(0, 0).unwrap().anchor, None, "align alone leaves the vertical anchor");
|
||||
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "diagonal"})).is_err());
|
||||
// A value that is not a name is refused, not read as absent (which would set the anchor to top).
|
||||
let rev = s.doc().unwrap().revision;
|
||||
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": 1})).is_err());
|
||||
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "left", "anchor": true})).is_err());
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
}
|
||||
|
||||
/// In Slide Master / layout view the insert commands edit the master or layout; their answers say
|
||||
/// so with `editing`, and on slides carry no such key.
|
||||
#[test]
|
||||
fn insert_answers_carry_editing_in_master_and_layout_view() {
|
||||
let mut s = session();
|
||||
let ins = json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]});
|
||||
s.execute("view.slideMaster", &json!({"layout": 0})).unwrap();
|
||||
let r = s.execute("shape.insert", &ins).unwrap();
|
||||
assert_eq!(r["editing"]["kind"], "layout", "{r}");
|
||||
assert!(r["editing"]["name"].is_string(), "{r}");
|
||||
let r = s.execute("insert.textBox", &json!({"rect": [10.0, 10.0, 100.0, 50.0], "text": "x"})).unwrap();
|
||||
assert_eq!(r["editing"]["kind"], "layout", "{r}");
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
let r = s.execute("shape.insert", &ins).unwrap();
|
||||
assert_eq!(r["editing"]["kind"], "master", "{r}");
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
let r = s.execute("shape.insert", &ins).unwrap();
|
||||
assert!(r.get("editing").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// MM4: moving a group's child through a command refits the group's box to its children, as
|
||||
/// PowerPoint does, and the children stay where they were drawn.
|
||||
#[test]
|
||||
fn moving_a_group_child_refits_the_group() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let ins = |s: &mut Session, p: serde_json::Value| s.execute("shape.insert", &p).unwrap()["id"].as_u64().unwrap();
|
||||
let a = ins(&mut s, json!({"preset": "rect", "rect": [100, 100, 100, 50]}));
|
||||
let b = ins(&mut s, json!({"preset": "rect", "rect": [300, 100, 100, 50]}));
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let gb = |s: &Session| s.doc().unwrap().shape(ShapeId(g as u32)).unwrap().xfrm.unwrap();
|
||||
assert_eq!((gb(&s).x, gb(&s).w), (100.0, 300.0));
|
||||
s.execute("shape.move", &json!({"id": b, "dx": 100, "dy": 60})).unwrap();
|
||||
let x = gb(&s);
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (100.0, 100.0, 400.0, 110.0), "group follows its children: {x:?}");
|
||||
// The child is where it was moved to on the slide.
|
||||
let r = s.execute("slide.inspect", &json!({})).unwrap();
|
||||
assert!(r.to_string().contains("400"), "{r}");
|
||||
s.execute("shape.setBounds", &json!({"ids": [a], "x": 20, "y": 20, "w": 100, "h": 50})).unwrap();
|
||||
let x = gb(&s);
|
||||
assert_eq!((x.x, x.y, x.w), (20.0, 20.0, 480.0), "{x:?}");
|
||||
}
|
||||
|
||||
/// HH9: Reset Picture discards the picture's formatting (adjustments, artistic effect, picture
|
||||
/// style effects and outline) but not the frame's shape (Change Shape / Crop to Shape).
|
||||
#[test]
|
||||
fn picture_reset_keeps_the_frame_shape() {
|
||||
let mut img = image::RgbaImage::new(40, 30);
|
||||
img.pixels_mut().for_each(|p| *p = image::Rgba([200, 30, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.picture", &json!({"name": "a.png", "data": cmd::base64_encode(&png)})).unwrap();
|
||||
s.execute("shape.change", &json!({"preset": "roundRect"})).unwrap();
|
||||
s.execute("picture.adjust", &json!({"brightness": 0.3})).unwrap();
|
||||
let geom = s.doc().unwrap().selected_shapes()[0].geom.clone();
|
||||
assert_ne!(geom, deckcraft_model::Geom::preset("rect"));
|
||||
s.execute("picture.reset", &json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().selected_shapes()[0].clone();
|
||||
assert_eq!(sh.geom, geom, "the frame shape stays");
|
||||
match &sh.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => assert_eq!(fill.adjust, Default::default()),
|
||||
_ => panic!("not a picture"),
|
||||
}
|
||||
let doc = command_specs().iter().find(|c| c.id == "picture.reset").unwrap().params;
|
||||
assert!(doc.contains("frame shape"), "{doc}");
|
||||
}
|
||||
|
||||
/// HH12: exporting the same slide twice to one PNG path after an edit overwrites the file with
|
||||
/// the new picture (not a bug: the second file differs).
|
||||
#[test]
|
||||
fn exporting_a_slide_again_overwrites_the_png_with_the_edit() {
|
||||
/// Removes the folder when the test ends, a failed assertion included.
|
||||
struct Cleanup(std::path::PathBuf);
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
// A folder of this run's own: created exclusively (never one an earlier or parallel run left).
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let dir = (0..100)
|
||||
.map(|k| std::env::temp_dir().join(format!("deckcraft-hh12-{}-{stamp}-{k}", std::process::id())))
|
||||
.find(|d| std::fs::create_dir(d).is_ok())
|
||||
.expect("a fresh temporary folder");
|
||||
let _cleanup = Cleanup(dir.clone());
|
||||
let png = dir.join("slide.png");
|
||||
let mut s = session();
|
||||
let path = png.to_string_lossy().to_string();
|
||||
s.execute("file.export", &json!({"path": path, "format": "png", "slide": 0, "scale": 0.5})).unwrap();
|
||||
let first = std::fs::read(&png).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 300]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#FF0000"})).unwrap();
|
||||
s.execute("file.export", &json!({"path": path, "format": "png", "slide": 0, "scale": 0.5})).unwrap();
|
||||
let second = std::fs::read(&png).unwrap();
|
||||
assert_ne!(first, second, "the second export replaced the file with the edited slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_button_fill_follows_the_theme() {
|
||||
let mut s = session();
|
||||
let id = s.execute("insert.actionButton", &json!({"kind": "next"})).unwrap()["id"].clone();
|
||||
let fill = |s: &mut Session| {
|
||||
let r = s.execute("slide.inspect", &json!({})).unwrap();
|
||||
r["shapes"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap()["fill"].clone()
|
||||
};
|
||||
let before = fill(&mut s);
|
||||
assert!(before.as_str().is_some_and(|f| f.starts_with('#')), "{before}");
|
||||
s.execute("design.theme", &json!({"name": "Ember"})).unwrap();
|
||||
let after = fill(&mut s);
|
||||
assert_ne!(before, after, "the button takes the new theme's accent1");
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.doc.slides[0].shapes.iter().find(|x| x.fill.is_none() && x.style.is_some()).unwrap();
|
||||
assert!(sh.fill.is_none(), "no explicit fill: the style binds it to accent1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() {
|
||||
let mut s = session();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-badslide-{}", std::process::id()));
|
||||
let path = dir.join("a.png");
|
||||
for slide in [json!(-1), json!(1.5), json!("two")] {
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "slide": slide, "scale": 0.1}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "export slide {slide} gave {r:?}");
|
||||
assert!(!path.exists(), "export wrote a file for {slide}");
|
||||
let r = s.execute("file.render", &json!({"slide": slide, "scale": 0.1}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "render slide {slide} gave {r:?}");
|
||||
let r = s.execute("slide.colorAt", &json!({"slide": slide, "x": 1, "y": 1, "scale": 0.1}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "colorAt slide {slide} gave {r:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_with_an_invalid_colour_is_rejected_and_keeps_the_highlight() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "ARCH"})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("format.highlight", &json!({"color": "yellow"})).unwrap();
|
||||
let has_highlight = |s: &mut Session| {
|
||||
let t = s.doc().unwrap().shape(ShapeId(title)).unwrap().text.clone().unwrap();
|
||||
t.paragraphs.iter().flat_map(|p| p.runs.iter()).any(|r| r.props.highlight.is_some())
|
||||
};
|
||||
assert!(has_highlight(&mut s));
|
||||
let r = s.execute("format.highlight", &json!({"color": "notacolour"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{r:?}");
|
||||
assert!(has_highlight(&mut s), "an invalid colour must not clear the highlight");
|
||||
s.execute("format.highlight", &json!({})).unwrap();
|
||||
assert!(!has_highlight(&mut s), "an absent colour clears the highlight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_layout_placeholder_format_inherits() {
|
||||
let mut s = Session::new();
|
||||
sample::open_sample(&mut s).unwrap();
|
||||
let title_of = |shapes: &[model::Shape]| shapes.iter().find(|sh| sh.ph.as_ref().is_some_and(|p| p.kind.is_title())).map(|sh| sh.id.0).unwrap();
|
||||
let existing = title_of(&s.doc().unwrap().doc.slides[0].shapes);
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 0})).unwrap();
|
||||
let layout_title = title_of(&s.doc().unwrap().doc.masters[0].layouts[0].shapes);
|
||||
s.execute("edit.select", &json!({"ids": [layout_title]})).unwrap();
|
||||
s.execute("format.font", &json!({"family": "Montserrat"})).unwrap();
|
||||
s.execute("format.align", &json!({"align": "right"})).unwrap();
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["fontRef"], "Montserrat", "layout placeholder: {st}");
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "title"})).unwrap();
|
||||
let slide = s.doc().unwrap().selection.slide;
|
||||
let fresh = title_of(&s.doc().unwrap().doc.slides[slide].shapes);
|
||||
for (index, id) in [(slide, fresh), (0, existing)] {
|
||||
s.execute("slide.go", &json!({"index": index})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["fontRef"], "Montserrat", "slide placeholder {id}: {st}");
|
||||
assert_eq!(st["align"], "r", "slide placeholder {id}: {st}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_body_placeholder_format_keeps_per_level_formatting() {
|
||||
let mut s = Session::new();
|
||||
sample::open_sample(&mut s).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
let body_idx =
|
||||
s.doc().unwrap().doc.masters[0].shapes.iter().position(|sh| sh.ph.as_ref().is_some_and(|p| p.kind == model::PhType::Body)).unwrap();
|
||||
let id = {
|
||||
let st = s.doc_mut().unwrap();
|
||||
let sh = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).masters[0]).shapes[body_idx];
|
||||
let mut body = model::TextBody::from_text("Alpha\nBeta");
|
||||
body.paragraphs[1].level = 1;
|
||||
for (l, size) in [(0u8, 32.0), (1, 28.0)] {
|
||||
let mut lvl = model::text::LevelStyle::default();
|
||||
lvl.run.size = Some(size);
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
sh.text = Some(body);
|
||||
sh.id
|
||||
};
|
||||
let level =
|
||||
|s: &Session, l: u8| s.doc().unwrap().doc.masters[0].shapes[body_idx].text.as_ref().unwrap().list_style.level(l).cloned().unwrap_or_default();
|
||||
// Bolding the level-2 paragraph reaches level 2 only.
|
||||
s.doc_mut().unwrap().selection.shapes = vec![id];
|
||||
s.doc_mut().unwrap().selection.text = Some(TextSel { shape: id, anchor: (1, 0), caret: (1, 4), cell: None, notes: false, line_end: None });
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
assert_eq!(level(&s, 1).run.bold, Some(true));
|
||||
for l in [0, 2, 3, 8] {
|
||||
assert_eq!(level(&s, l).run.bold, None, "level {}", l + 1);
|
||||
}
|
||||
// Grow and shrink step each level from its own size.
|
||||
s.doc_mut().unwrap().selection.text = None;
|
||||
// The text itself: a run's explicit size, else its level's effective size.
|
||||
let shown = |s: &Session| -> Vec<Option<f64>> {
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||||
let sh = &st.doc.masters[0].shapes[body_idx];
|
||||
let body = sh.text.as_ref().unwrap();
|
||||
body.paragraphs.iter().map(|p| p.runs[0].props.size.or(deckcraft_model::resolve::level_style(&ctx, sh, p.level).run.size)).collect()
|
||||
};
|
||||
s.execute("format.grow", &json!({})).unwrap();
|
||||
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(36.0), Some(32.0)));
|
||||
assert_eq!(shown(&s), vec![Some(36.0), Some(32.0)]);
|
||||
s.execute("format.shrink", &json!({})).unwrap();
|
||||
s.execute("format.shrink", &json!({})).unwrap();
|
||||
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
|
||||
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_master_view_rejects_invalid_indices() {
|
||||
let mut s = Session::with_new();
|
||||
for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] {
|
||||
let r = s.execute("view.slideMaster", &p);
|
||||
assert!(r.is_err(), "invalid index accepted for {p}: {r:?}");
|
||||
}
|
||||
assert!(s.execute("view.slideMaster", &json!({})).is_ok());
|
||||
assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_to_image_path_is_an_error_not_zip_bytes() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-save-img-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (cmd, name) in [("file.save", "a.png"), ("file.saveAs", "a.jpg"), ("file.save", "a.jpeg")] {
|
||||
let mut s = Session::new();
|
||||
sample::open_sample(&mut s).unwrap();
|
||||
let path = dir.join(name).to_string_lossy().to_string();
|
||||
let before = s.doc().unwrap().path.clone();
|
||||
assert!(s.execute(cmd, &json!({"path": path})).is_err(), "{cmd} {name} should fail");
|
||||
assert!(!std::path::Path::new(&path).exists(), "{name} must not be written");
|
||||
assert_eq!(s.doc().unwrap().path, before);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -150,8 +150,10 @@ enum Drag {
|
||||
},
|
||||
Resize {
|
||||
id: ShapeId,
|
||||
handle: u8,
|
||||
orig: Xfrm,
|
||||
ids: Vec<ShapeId>,
|
||||
handle: u8,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
start: Point,
|
||||
},
|
||||
LineEnd {
|
||||
|
||||
@@ -206,7 +206,7 @@ impl Session {
|
||||
let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) };
|
||||
let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0);
|
||||
let (start, frame) = (f.local(p), f.affine);
|
||||
if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) {
|
||||
if st.selection.text.as_ref().is_none_or(|t| t.shape != id) {
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
|
||||
@@ -13,6 +13,7 @@ struct MoveDrag {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Update the active drag interaction using the pointer position and modifiers.
|
||||
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
|
||||
match drag {
|
||||
@@ -26,10 +27,48 @@ impl Session {
|
||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
|
||||
}
|
||||
Drag::Resize { id, handle, orig, start } => {
|
||||
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
|
||||
let nx = self.snap_resize(nx, &orig, handle, id, tol);
|
||||
self.set_xfrm(id, nx)?;
|
||||
Drag::Resize { id, orig, ids, handle, origs, start } => {
|
||||
let keep_aspect = mods.shift || self.is_picture(id);
|
||||
let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
|
||||
let nx = self.snap_resize(nx, handle, &ids, tol);
|
||||
let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
|
||||
let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
|
||||
let div = if mods.alt { 2.0 } else { 1.0 };
|
||||
let hx = match handle {
|
||||
0 | 6 | 7 => -1.0,
|
||||
2..=4 => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let hy = match handle {
|
||||
0..=2 => -1.0,
|
||||
4..=6 => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
for (tid, o) in &origs {
|
||||
let target_nx = if *tid == id {
|
||||
nx
|
||||
} else {
|
||||
let picture = self.is_picture(*tid);
|
||||
let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
|
||||
// Use the axis with the larger relative change so an unchanged
|
||||
// dimension cannot cancel shrinking. Preserve each axis's flip.
|
||||
let scale_x = 1.0 + sx;
|
||||
let scale_y = 1.0 + sy;
|
||||
let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
|
||||
(scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
|
||||
} else {
|
||||
(sx, sy)
|
||||
};
|
||||
|
||||
let lp = Affine::rotate(o.rot.to_radians())
|
||||
* Point::new(
|
||||
hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
|
||||
hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
|
||||
);
|
||||
resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
|
||||
};
|
||||
self.set_xfrm(*tid, target_nx)?;
|
||||
}
|
||||
}
|
||||
Drag::LineEnd { id, end, orig } => {
|
||||
let a = orig.affine();
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
|
||||
pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
|
||||
pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
|
||||
if x.rot != 0.0 {
|
||||
self.tool.guides.clear();
|
||||
return x;
|
||||
@@ -241,7 +241,7 @@ impl Session {
|
||||
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
|
||||
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
|
||||
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
|
||||
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||
let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||
let mut nx = x;
|
||||
if moves_left {
|
||||
nx.x += off.x;
|
||||
|
||||
@@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits.
|
||||
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let inv = x.affine().inverse();
|
||||
@@ -267,8 +268,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
|
||||
return None;
|
||||
}
|
||||
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
|
||||
// Empty interiors of unfilled, text-less shapes don't catch clicks.
|
||||
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
|
||||
// Match rendering: an explicit No Fill overrides the style, and style/theme fills
|
||||
// can themselves be No Fill. Empty, text-less interiors then let clicks through.
|
||||
let fill =
|
||||
cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
|
||||
let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let in_geo = {
|
||||
use deckcraft_geom::KurboShape;
|
||||
|
||||
@@ -220,6 +220,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a pointer press and start the applicable tool interaction.
|
||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// Next click of a freeform: close on the start point, else add a vertex.
|
||||
@@ -303,8 +304,12 @@ impl Session {
|
||||
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
|
||||
Some(Hit::Resize { id, handle }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
let ids: Vec<ShapeId> = origs.iter().map(|(id, _)| *id).collect();
|
||||
self.begin("Size");
|
||||
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
|
||||
self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
|
||||
}
|
||||
Some(Hit::LineEnd { id, end }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
@@ -436,6 +441,7 @@ impl Session {
|
||||
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
|
||||
}
|
||||
|
||||
/// Handle a pointer release and finalize the active tool interaction.
|
||||
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
||||
let drag = self.tool.drag.take();
|
||||
self.tool.guides.clear();
|
||||
@@ -455,12 +461,11 @@ impl Session {
|
||||
self.execute("guide.move", &json!({"index": index, "pos": pos}))?
|
||||
};
|
||||
}
|
||||
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
|
||||
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. })
|
||||
// Plain click on text: caret at the click (in a table, in the clicked cell).
|
||||
if self.edit_text_at(id, at)? {
|
||||
if self.edit_text_at(id, at)? => {
|
||||
out = json!({"editing": id});
|
||||
}
|
||||
}
|
||||
Some(Drag::Marquee { start, add }) => {
|
||||
let r = Rect::from_points(start, p);
|
||||
self.tool.marquee = None;
|
||||
@@ -559,10 +564,22 @@ impl Session {
|
||||
out = self.add_ink(points)?;
|
||||
}
|
||||
}
|
||||
Some(Drag::Resize { id, .. }) => {
|
||||
Some(Drag::Resize { origs, .. }) => {
|
||||
let changed: Vec<ShapeId> = origs
|
||||
.iter()
|
||||
.filter_map(|(id, orig)| {
|
||||
let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
|
||||
|
||||
(current != *orig).then_some(*id)
|
||||
})
|
||||
.collect();
|
||||
if !changed.is_empty() {
|
||||
for id in changed {
|
||||
let _ = cmd::text::fit_text_box(self, id);
|
||||
self.refit_after_resize(id)?;
|
||||
}
|
||||
}
|
||||
self.commit();
|
||||
let _ = cmd::text::fit_text_box(self, id);
|
||||
self.refit_after_resize(id)?;
|
||||
}
|
||||
Some(Drag::TextSelect { before, .. }) => {
|
||||
out = self.paint_if_selection_changed(before, out)?;
|
||||
|
||||
@@ -227,6 +227,9 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most caret records read for one ligature glyph.
|
||||
const MAX_LIGATURE_CARETS: usize = 64;
|
||||
|
||||
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
||||
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
||||
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
@@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
let Ok(cov) = list.coverage() else { return Vec::new() };
|
||||
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
||||
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
||||
// The limit applies to the records read, not to the carets kept: a font can list thousands of
|
||||
// records it then has to be read through for every ligature laid out.
|
||||
let mut out: Vec<i32> = lig
|
||||
.caret_values()
|
||||
.iter()
|
||||
.take(MAX_LIGATURE_CARETS)
|
||||
.filter_map(|cv| cv.ok())
|
||||
.filter_map(|cv| match cv {
|
||||
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
||||
@@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
// Format 2 is a contour point index; outlines aren't consulted here.
|
||||
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
|
||||
+292
-27
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
|
||||
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
|
||||
/// where one exists), tried in order after the family itself.
|
||||
pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
match family.to_ascii_lowercase().as_str() {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if is_monospace_family(&f) {
|
||||
return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
|
||||
}
|
||||
match f.as_str() {
|
||||
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
|
||||
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
|
||||
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
|
||||
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
|
||||
"courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
|
||||
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
|
||||
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
|
||||
}
|
||||
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families that are monospaced: when not installed, documents keep a monospaced substitute
|
||||
/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
|
||||
/// proportional Calibri.
|
||||
pub fn is_monospace_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
let s = f.trim();
|
||||
if matches!(
|
||||
s,
|
||||
"courier new"
|
||||
| "courier"
|
||||
| "cousine"
|
||||
| "consolas"
|
||||
| "menlo"
|
||||
| "monaco"
|
||||
| "source code pro"
|
||||
| "jetbrains mono"
|
||||
| "cascadia code"
|
||||
| "cascadia code pl"
|
||||
| "cascadia mono"
|
||||
| "cascadia mono pl"
|
||||
| "fira code"
|
||||
| "fira mono"
|
||||
| "inconsolata"
|
||||
| "dejavu sans mono"
|
||||
| "sf mono"
|
||||
| "roboto mono"
|
||||
| "hack"
|
||||
| "ubuntu mono"
|
||||
| "pt mono"
|
||||
| "liberation mono"
|
||||
| "andale mono"
|
||||
| "lucida console"
|
||||
| "lucida sans typewriter"
|
||||
| "fixedsys"
|
||||
| "terminal"
|
||||
| "monospace"
|
||||
| "iosevka"
|
||||
| "monolisa"
|
||||
| "envy code r"
|
||||
| "hasklig"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
s.starts_with("courier")
|
||||
|| s.starts_with("consolas")
|
||||
|| s.starts_with("inconsolata")
|
||||
|| s.starts_with("iosevka")
|
||||
|| s.ends_with(" mono")
|
||||
|| s.ends_with(" code")
|
||||
|| s.ends_with(" console")
|
||||
|| s.ends_with(" typewriter")
|
||||
|| s.ends_with(" monospace")
|
||||
|| s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
"calibri"
|
||||
| "calibri light"
|
||||
| "cambria"
|
||||
| "cambria math"
|
||||
| "arial"
|
||||
| "helvetica"
|
||||
| "times new roman"
|
||||
| "times"
|
||||
| "courier new"
|
||||
| "courier"
|
||||
| "cousine"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -300,7 +368,7 @@ struct CatalogEntry {
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
outlines: Mutex<OutlineCache>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
@@ -371,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
|
||||
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
|
||||
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
|
||||
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
|
||||
/// starts over: distinct glyph ids can each expand one dense outline.
|
||||
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
|
||||
|
||||
/// Glyph outlines by (face, glyph), with the path elements they hold.
|
||||
#[derive(Default)]
|
||||
struct OutlineCache {
|
||||
paths: HashMap<(u32, u32), Arc<BezPath>>,
|
||||
elements: usize,
|
||||
}
|
||||
|
||||
impl OutlineCache {
|
||||
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
|
||||
self.paths.get(key).cloned()
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
|
||||
let n = path.elements().len();
|
||||
if let Some(old) = self.paths.remove(&key) {
|
||||
self.elements = self.elements.saturating_sub(old.elements().len());
|
||||
}
|
||||
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
|
||||
self.paths.clear();
|
||||
self.elements = 0;
|
||||
}
|
||||
self.paths.insert(key, path);
|
||||
self.elements = self.elements.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
@@ -393,6 +493,22 @@ fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String
|
||||
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
|
||||
}
|
||||
|
||||
/// The Windows family and style a variable font's named instance answers to. Windows lists each
|
||||
/// instance other than Regular, Italic, Bold and Bold Italic as a family of its own, as the
|
||||
/// family's static files name themselves: "Montserrat ExtraBold" / "Regular" for the ExtraBold
|
||||
/// instance, "Montserrat ExtraBold" / "Italic" for ExtraBold Italic.
|
||||
fn instance_legacy_names(family: &str, style: &str) -> Option<(String, String)> {
|
||||
if style.contains('{') {
|
||||
return None;
|
||||
}
|
||||
let weight: Vec<&str> = style.split_whitespace().filter(|w| !matches!(norm(w).as_str(), "italic" | "oblique")).collect();
|
||||
let weight = weight.join(" ");
|
||||
if matches!(norm(&weight).as_str(), "" | "regular" | "bold") {
|
||||
return None;
|
||||
}
|
||||
Some((format!("{family} {weight}"), if style_italic(style) { "Italic" } else { "Regular" }.into()))
|
||||
}
|
||||
|
||||
/// The tables skrifa reads glyph outlines from: `glyf` (TrueType), `CFF `, `CFF2` and `VARC`. For a
|
||||
/// face with none of them (bitmaps only, or outlines in Apple's `hvgl` table), [`FontDb::outline`]
|
||||
/// returns an empty path for every glyph.
|
||||
@@ -604,29 +720,76 @@ fn fontconfig_dirs(text: &str, data_home: Option<&str>, home: Option<&std::path:
|
||||
/// axis settings.
|
||||
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
|
||||
|
||||
/// Maximum number of faces we enumerate from a single font collection.
|
||||
const MAX_COLLECTION_FACES: u32 = 256;
|
||||
/// Maximum collection entries inspected while looking for distinct table directories.
|
||||
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
|
||||
/// Maximum named instances read from a single face.
|
||||
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
|
||||
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
|
||||
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
|
||||
/// is then built with its own shaper.
|
||||
const MAX_ENUMERATED_FACES: usize = 4096;
|
||||
|
||||
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
|
||||
let mut offsets = std::collections::HashSet::new();
|
||||
let mut indices = Vec::with_capacity(MAX_COLLECTION_FACES as usize);
|
||||
for (index, offset) in entries.into_iter().take(MAX_COLLECTION_ENTRIES_SCANNED as usize) {
|
||||
if offsets.insert(offset) {
|
||||
indices.push(index);
|
||||
if indices.len() == MAX_COLLECTION_FACES as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
fn capped_named_instance_count(count: usize) -> usize {
|
||||
count.min(MAX_NAMED_INSTANCES_PER_FACE)
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection); a variable font yields one face per
|
||||
/// named instance (InDesign lists them as styles).
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
let count = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Err(_) => 0,
|
||||
let indices = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => vec![0],
|
||||
Ok(FileRef::Collection(c)) => {
|
||||
let entries = (0..c.len().min(MAX_COLLECTION_ENTRIES_SCANNED)).filter_map(|i| {
|
||||
let start = 12usize.checked_add((i as usize).checked_mul(4)?)?;
|
||||
let end = start.checked_add(4)?;
|
||||
let offset = u32::from_be_bytes(data.get(start..end)?.try_into().ok()?);
|
||||
Some((i, offset))
|
||||
});
|
||||
distinct_face_indices(entries)
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for i in 0..count {
|
||||
// Named instances read and plain faces kept, across the whole file.
|
||||
let mut budget = MAX_ENUMERATED_FACES;
|
||||
for i in indices {
|
||||
if budget == 0 {
|
||||
break;
|
||||
}
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
let Some((family, style)) = face_names(&f) else { continue };
|
||||
let axes = f.axes();
|
||||
let mut named = Vec::new();
|
||||
for ni in f.named_instances().iter() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let instances = f.named_instances();
|
||||
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
|
||||
let Some(left) = budget.checked_sub(1) else { break };
|
||||
budget = left;
|
||||
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
|
||||
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
|
||||
if !seen.insert(style.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
|
||||
named.push((i, style, coords));
|
||||
}
|
||||
if named.is_empty() {
|
||||
budget = budget.saturating_sub(1);
|
||||
out.push((i, family, style, Vec::new()));
|
||||
} else {
|
||||
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
|
||||
@@ -686,7 +849,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { instance_legacy_names(&family, &style) };
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
@@ -787,7 +950,7 @@ impl FontDb {
|
||||
}
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
outlines: Mutex::new(OutlineCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
font_dirs,
|
||||
@@ -1030,6 +1193,7 @@ impl FontDb {
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
@@ -1038,6 +1202,9 @@ impl FontDb {
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
if is_monospace_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
@@ -1391,41 +1558,72 @@ impl FontDb {
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
return p;
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, p.clone());
|
||||
let p = Arc::new(pen.finish());
|
||||
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
|
||||
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
|
||||
struct FlipPen {
|
||||
path: BezPath,
|
||||
limit: usize,
|
||||
over: bool,
|
||||
}
|
||||
|
||||
impl FlipPen {
|
||||
fn new(limit: usize) -> Self {
|
||||
FlipPen { path: BezPath::new(), limit, over: false }
|
||||
}
|
||||
|
||||
/// Room for one more element?
|
||||
fn room(&mut self) -> bool {
|
||||
if !self.over && self.path.elements().len() >= self.limit {
|
||||
self.over = true;
|
||||
self.path = BezPath::new();
|
||||
}
|
||||
!self.over
|
||||
}
|
||||
|
||||
fn finish(self) -> BezPath {
|
||||
if self.over { BezPath::new() } else { self.path }
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.move_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.line_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
if self.room() {
|
||||
self.path.close_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1435,6 +1633,73 @@ mod font_dir_tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duplicate_collection_offsets_do_not_consume_face_cap() {
|
||||
let mut entries = vec![(0, 7); MAX_COLLECTION_FACES as usize];
|
||||
entries.extend((0..MAX_COLLECTION_FACES).map(|i| (i + MAX_COLLECTION_FACES, i + 8)));
|
||||
let faces = distinct_face_indices(entries);
|
||||
assert_eq!(faces.len(), MAX_COLLECTION_FACES as usize);
|
||||
assert_eq!(faces.first(), Some(&0));
|
||||
assert_eq!(faces.get(1), Some(&MAX_COLLECTION_FACES));
|
||||
assert_eq!(faces.last(), Some(&(MAX_COLLECTION_FACES * 2 - 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_instance_limit_is_bounded_per_face() {
|
||||
assert_eq!(capped_named_instance_count(0), 0);
|
||||
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE), MAX_NAMED_INSTANCES_PER_FACE);
|
||||
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_of_one_file_are_bounded_across_the_collection() {
|
||||
// The per-collection and per-face limits together would allow far more.
|
||||
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
|
||||
for data in bundled() {
|
||||
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outline_past_the_element_limit_draws_nothing() {
|
||||
let mut pen = FlipPen::new(8);
|
||||
pen.move_to(0.0, 0.0);
|
||||
pen.line_to(1.0, 1.0);
|
||||
pen.close();
|
||||
assert_eq!(pen.finish().elements().len(), 3);
|
||||
// A shared outline drawn over and over (composite and VARC glyphs).
|
||||
let mut pen = FlipPen::new(8);
|
||||
for i in 0..1_000_000 {
|
||||
pen.move_to(0.0, 0.0);
|
||||
pen.line_to(i as f32, 1.0);
|
||||
pen.close();
|
||||
}
|
||||
assert_eq!(pen.path.elements().len(), 0);
|
||||
assert!(pen.finish().elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_cache_is_bounded_by_elements() {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
for i in 1..MAX_OUTLINE_ELEMENTS {
|
||||
path.line_to((i as f64, 0.0));
|
||||
}
|
||||
let path = Arc::new(path);
|
||||
let mut cache = OutlineCache::default();
|
||||
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
|
||||
for gid in 0..glyphs as u32 {
|
||||
cache.insert((1, gid), path.clone());
|
||||
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
|
||||
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
|
||||
}
|
||||
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
|
||||
// Replacing an entry does not count it twice.
|
||||
let before = cache.elements;
|
||||
cache.insert((1, glyphs as u32 - 1), path.clone());
|
||||
assert_eq!(cache.elements, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_data_dirs_contribute_their_fonts_folders() {
|
||||
let home = Path::new("/home/ann");
|
||||
|
||||
@@ -15,7 +15,8 @@ mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
|
||||
system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
|
||||
+161
-38
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
|
||||
pub language: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Most bytes of text given to the shaper at once. The shaper lets one call grow its buffers to
|
||||
/// 256 glyphs per character (a font's substitutions decide how many), so a long item is shaped in
|
||||
/// pieces of this size, each with the rest of the paragraph as context.
|
||||
const MAX_SHAPE_CHUNK: usize = 1024;
|
||||
/// Most glyphs kept per character of a piece: a piece the font expands further is laid out one
|
||||
/// glyph per character from the cmap instead.
|
||||
const MAX_GLYPHS_PER_CHAR: usize = 32;
|
||||
|
||||
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
||||
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
||||
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
||||
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||
let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let chunks = chunks(text, MAX_SHAPE_CHUNK);
|
||||
let mut pieces: Vec<Vec<ShapedGlyph>> = Vec::with_capacity(chunks.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), (range.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
}
|
||||
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||
{
|
||||
buf.set_language(l);
|
||||
}
|
||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
// Pieces of one item share the script the whole item would be guessed to have.
|
||||
let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
|
||||
let mut probe = UnicodeBuffer::new();
|
||||
for c in text.chars() {
|
||||
probe.add(map(c), 0);
|
||||
}
|
||||
probe.guess_segment_properties();
|
||||
probe.script()
|
||||
});
|
||||
for c in &chunks {
|
||||
let r = (range.start + c.start)..(range.start + c.end);
|
||||
let (Some(pre), Some(piece), Some(post)) = (para.get(..r.start), para.get(r.clone()), para.get(r.end..)) else { continue };
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in piece.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
} else if let Some(s) = item_script {
|
||||
buf.set_script(s);
|
||||
}
|
||||
if let Some(l) =
|
||||
params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||
{
|
||||
buf.set_language(l);
|
||||
}
|
||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||
let budget = piece.chars().count().saturating_mul(MAX_GLYPHS_PER_CHAR);
|
||||
if gb.len() > budget {
|
||||
// Font-controlled expansion past anything real text needs: keep it bounded.
|
||||
pieces.push(cmap_glyphs(face, para, r, params.rtl, &map));
|
||||
continue;
|
||||
}
|
||||
pieces.push(
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
if shaped.is_none() {
|
||||
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
if params.rtl {
|
||||
out.reverse();
|
||||
return cmap_glyphs(face, para, range, params.rtl, &map);
|
||||
}
|
||||
// Each piece is in visual order; right to left, the later pieces come first.
|
||||
if params.rtl {
|
||||
pieces.reverse();
|
||||
}
|
||||
let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
|
||||
for p in pieces {
|
||||
out.extend(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One glyph per character of `para[range]` from the cmap, in visual order.
|
||||
fn cmap_glyphs(face: &FontFace, para: &str, range: Range<usize>, rtl: bool, map: &impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let (Some(text), Some(f)) = (para.get(range.clone()), face.skrifa()) else { return Vec::new() };
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
if rtl {
|
||||
out.reverse();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `text` cut into byte ranges of at most `max` bytes (or one character, if longer), at the last
|
||||
/// space of a piece where there is one, else before a character that starts a cluster (not a
|
||||
/// combining mark or joiner, not after a joiner). Text that fits is one piece.
|
||||
fn chunks(text: &str, max: usize) -> Vec<Range<usize>> {
|
||||
use unicode_bidi::BidiClass::{BN, NSM};
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
while text.len() - start > max {
|
||||
let window = text.get(start..).unwrap_or_default();
|
||||
let mut space = None;
|
||||
let mut boundary = None;
|
||||
let mut any = None;
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, c) in window.char_indices() {
|
||||
if i > max {
|
||||
break;
|
||||
}
|
||||
if i > 0 {
|
||||
any = Some(i);
|
||||
let joined = prev.is_some_and(|p| matches!(unicode_bidi::bidi_class(p), BN));
|
||||
if !joined && !matches!(unicode_bidi::bidi_class(c), NSM | BN) {
|
||||
boundary = Some(i);
|
||||
if prev.is_some_and(char::is_whitespace) {
|
||||
space = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
prev = Some(c);
|
||||
}
|
||||
// A piece always moves forward by at least one character.
|
||||
let first = window.chars().next().map_or(window.len(), char::len_utf8);
|
||||
let cut = space.filter(|&i| i >= max / 2).or(boundary).or(any).unwrap_or(first).max(first);
|
||||
out.push(start..start + cut);
|
||||
start += cut;
|
||||
}
|
||||
if start < text.len() || out.is_empty() {
|
||||
out.push(start..text.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -121,4 +211,37 @@ mod tests {
|
||||
// Not a char boundary.
|
||||
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_items_are_shaped_in_bounded_pieces() {
|
||||
for text in ["word ".repeat(2_000), "e\u{301}".repeat(2_000), "x".repeat(5_000), "\u{1f469}\u{200d}\u{1f4bb} ".repeat(500)] {
|
||||
let pieces = chunks(&text, MAX_SHAPE_CHUNK);
|
||||
assert_eq!(pieces.first().map(|r| r.start), Some(0));
|
||||
assert_eq!(pieces.last().map(|r| r.end), Some(text.len()));
|
||||
for w in pieces.windows(2) {
|
||||
assert_eq!(w[0].end, w[1].start);
|
||||
}
|
||||
for r in &pieces {
|
||||
assert!(!r.is_empty() && r.len() <= MAX_SHAPE_CHUNK);
|
||||
// Never before a combining mark, never after a joiner.
|
||||
let rest = &text[r.end..];
|
||||
assert!(!rest.starts_with('\u{301}'));
|
||||
assert!(!text[..r.end].ends_with('\u{200d}') || rest.is_empty());
|
||||
}
|
||||
}
|
||||
assert_eq!(chunks("short", MAX_SHAPE_CHUNK), vec![0..5]);
|
||||
assert_eq!(chunks("", MAX_SHAPE_CHUNK), vec![0..0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_items_keep_every_cluster_in_visual_order() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let text = "ab cd ".repeat(1_000);
|
||||
let g = shape_range(&face, &text, 0..text.len(), &ShapeParams::default(), |c| c);
|
||||
assert_eq!(g.len(), text.len());
|
||||
assert!(g.windows(2).all(|w| w[0].cluster < w[1].cluster));
|
||||
let rtl = shape_range(&face, &text, 0..text.len(), &ShapeParams { rtl: true, ..Default::default() }, |c| c);
|
||||
assert_eq!(rtl.len(), text.len());
|
||||
assert!(rtl.windows(2).all(|w| w[0].cluster > w[1].cluster));
|
||||
}
|
||||
}
|
||||
|
||||
+104
-3
@@ -147,6 +147,36 @@ fn windows_family_names_find_their_faces() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_font_instances_answer_to_their_windows_names() {
|
||||
// Windows lists a variable font's named instances other than Regular, Italic, Bold and
|
||||
// Bold Italic as families of their own, and documents name them that way ("Montserrat
|
||||
// ExtraBold"). The bundled Montserrat is variable, so these names found no face and drew
|
||||
// in Inter Regular, even with the family's static files installed (their faces are
|
||||
// skipped as already present).
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
if !db.is_loaded("Montserrat") {
|
||||
return; // built without craft-fonts
|
||||
}
|
||||
for (name, style, weight, italic) in [
|
||||
("Montserrat ExtraBold", "Regular", 800.0, false),
|
||||
("Montserrat SemiBold", "Regular", 600.0, false),
|
||||
("Montserrat Medium", "Regular", 500.0, false),
|
||||
("Montserrat Medium", "Italic", 500.0, true),
|
||||
("Montserrat Thin", "Bold", 100.0, false),
|
||||
] {
|
||||
assert!(db.has_family(name), "{name}");
|
||||
let f = db.face(name, style);
|
||||
assert_eq!((f.family.as_str(), f.weight, f.italic), ("Montserrat", weight, italic), "{name} {style}");
|
||||
assert_eq!(db.document_face(name, style).family, "Montserrat", "{name}: installed, so not Calibri");
|
||||
}
|
||||
// Regular and Bold are the family itself, not families of their own.
|
||||
assert!(!db.has_family("Montserrat Regular"));
|
||||
assert!(!db.has_family("Montserrat Bold"));
|
||||
assert_eq!(db.face("Montserrat", "Bold").weight, 700.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_without_outlines_are_not_cataloged() {
|
||||
// Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in
|
||||
@@ -186,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
for (missing, twin) in [
|
||||
("Liberation Sans", "Arial"),
|
||||
("Liberation Serif", "Times New Roman"),
|
||||
("Arial CE", "Arial"),
|
||||
("Times New Roman CYR", "Times New Roman"),
|
||||
("Courier New CE", "Courier New"),
|
||||
] {
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
@@ -207,6 +241,73 @@ fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
|
||||
assert!(substitutes("Aptos").contains(&"Inter"));
|
||||
assert!(substitutes("zzz").is_empty());
|
||||
|
||||
for mono in [
|
||||
"Courier New",
|
||||
"Cousine",
|
||||
"Consolas",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono",
|
||||
"Cascadia Code",
|
||||
"Cascadia Code PL",
|
||||
"Cascadia Mono",
|
||||
"Fira Code",
|
||||
"Inconsolata",
|
||||
"DejaVu Sans Mono",
|
||||
"Roboto Mono",
|
||||
"Hack",
|
||||
"Ubuntu Mono",
|
||||
"Custom Monospace",
|
||||
"Fantasque Sans Mono",
|
||||
] {
|
||||
assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute");
|
||||
assert!(is_monospace_family(mono), "{mono} should be recognized as monospace");
|
||||
}
|
||||
|
||||
for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] {
|
||||
assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace");
|
||||
}
|
||||
|
||||
// Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
|
||||
// and ensuring proportional families maintain their unchanged substitutes.
|
||||
assert!(is_monospace_family("Consolas"));
|
||||
assert!(is_monospace_family("Courier New"));
|
||||
assert!(is_monospace_family("Fira Code"));
|
||||
assert!(!is_monospace_family("Monotype Corsiva"));
|
||||
assert!(!is_monospace_family("Monoton"));
|
||||
|
||||
assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]);
|
||||
assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]);
|
||||
assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]);
|
||||
assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]);
|
||||
assert!(substitutes("Monotype Corsiva").is_empty());
|
||||
assert!(substitutes("Monoton").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_monospace_fonts_keep_monospace_fallback() {
|
||||
let db = FontDb::global();
|
||||
let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone();
|
||||
let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone();
|
||||
|
||||
assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace");
|
||||
|
||||
// Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri.
|
||||
if !db.has_family("Cousine") {
|
||||
assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub);
|
||||
}
|
||||
|
||||
// Monospace fonts missing from the system must resolve to a monospace substitute.
|
||||
for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] {
|
||||
let f = db.document_face(mono, "Regular");
|
||||
assert_eq!(f.family, mono_sub, "{mono}");
|
||||
assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family");
|
||||
if mono_sub != calibri_target {
|
||||
assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
|
||||
let mut p = fixture(vec![7; 600]);
|
||||
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
|
||||
let bytes = save(&p).unwrap();
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
|
||||
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
|
||||
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
|
||||
let duplicated = rewrite_manifest(&bytes, |manifest| {
|
||||
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
|
||||
media.push(media[0].clone());
|
||||
});
|
||||
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
|
||||
assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
|
||||
part.remove("nativeEntry");
|
||||
part.insert("bytes".into(), json!(vec![9_u8; 600]));
|
||||
});
|
||||
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
|
||||
assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
|
||||
assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
|
||||
}
|
||||
// A failed CRC may already have inflated bytes. Continuing with the same remaining
|
||||
// budget would let each corrupt member consume that work budget again.
|
||||
let result = load_within(&bytes, Budget { total: 10, items: 2 });
|
||||
let result = load_within(&bytes, Budget::media(10, 2));
|
||||
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
|
||||
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
|
||||
}
|
||||
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
|
||||
assert_eq!(replaced, 2);
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
|
||||
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
|
||||
let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
|
||||
+58
-13
@@ -29,12 +29,24 @@ const MAX_ENTRY: u64 = 2 << 30;
|
||||
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
|
||||
/// Media items and chart sidecar parts one file may list.
|
||||
const MAX_MEDIA_ITEMS: usize = 100_000;
|
||||
/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
|
||||
/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
|
||||
const MAX_MANIFEST: u64 = 256 << 20;
|
||||
|
||||
/// What one load may inflate.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Budget {
|
||||
total: u64,
|
||||
items: usize,
|
||||
manifest: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Budget {
|
||||
/// A media budget with the default manifest limit.
|
||||
fn media(total: u64, items: usize) -> Self {
|
||||
Budget { total, items, manifest: MAX_MANIFEST }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -107,6 +119,7 @@ pub fn sniff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
|
||||
read_entry_max(z, name, MAX_ENTRY)
|
||||
}
|
||||
@@ -114,13 +127,30 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
|
||||
/// Read one member, refusing it before inflating if it declares more than `max` bytes, and
|
||||
/// stopping at `max` if it inflates further than it declared.
|
||||
fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
|
||||
read_entry_counted(z, name, max, &mut 0)
|
||||
}
|
||||
|
||||
/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
|
||||
/// including those inflated before a decode or CRC error, so a member that fails late still
|
||||
/// spends the shared budget it used.
|
||||
fn read_entry_charged(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, left: &mut u64) -> Result<Vec<u8>> {
|
||||
let mut spent = 0;
|
||||
let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
|
||||
*left = left.saturating_sub(spent);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
|
||||
fn read_entry_counted(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64, spent: &mut u64) -> Result<Vec<u8>> {
|
||||
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
if f.size() > max {
|
||||
return Err(FormatError::Damaged(format!("{name} is too large")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
|
||||
let mut f = f.take(max.saturating_add(1));
|
||||
f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
let read = f.read_to_end(&mut out);
|
||||
*spent = spent.saturating_add(out.len() as u64);
|
||||
read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
if out.len() as u64 > max {
|
||||
return Err(FormatError::Damaged(format!("{name} is too large")));
|
||||
}
|
||||
@@ -137,12 +167,15 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
|
||||
/// Linked media saved without its bytes isn't damage.
|
||||
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
|
||||
}
|
||||
|
||||
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
|
||||
if z.index_for_name("presentation.json").is_none() {
|
||||
return Err(FormatError::NotOurs("no presentation.json".into()));
|
||||
}
|
||||
let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
|
||||
let m = parse_manifest(&json)?;
|
||||
if m.format != "deckcraft" {
|
||||
return Err(FormatError::NotOurs(m.format));
|
||||
@@ -164,11 +197,9 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
|
||||
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
|
||||
}
|
||||
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
|
||||
Ok(data) => {
|
||||
left -= data.len() as u64;
|
||||
item.data = Arc::new(data);
|
||||
}
|
||||
// A failed read is charged for what it inflated, so later members can't reuse that budget.
|
||||
match read_entry_charged(&mut z, &name, &mut left) {
|
||||
Ok(data) => item.data = Arc::new(data),
|
||||
Err(_) if linked_only => {}
|
||||
Err(e) => damaged.push(e.to_string()),
|
||||
}
|
||||
@@ -188,9 +219,11 @@ pub fn repair(p: &mut Presentation) {
|
||||
}
|
||||
p.fix_next_id();
|
||||
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
// Indexed once: a per-slide scan of every layout is slides × layouts work.
|
||||
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in 0..p.slides.len() {
|
||||
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
|
||||
let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
|
||||
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
|
||||
if needs_layout || dup {
|
||||
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
|
||||
@@ -331,16 +364,20 @@ fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))
|
||||
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
|
||||
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
|
||||
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
|
||||
let mut src = json.to_vec();
|
||||
let mut last = match failure(&src) {
|
||||
// Parse the inflated text as it is: only a file that fails on a `null` number is copied for
|
||||
// patching (the copy is bounded by `MAX_MANIFEST`).
|
||||
let mut last = match failure(json) {
|
||||
Ok(m) => return Ok(m),
|
||||
Err(f) => f,
|
||||
};
|
||||
if !is_null_number(&last.0) {
|
||||
return Err(FormatError::Damaged(last.0));
|
||||
}
|
||||
let mut src = json.to_vec();
|
||||
let mut budget = MAX_REPARSE_BYTES;
|
||||
'fixes: for _ in 0..MAX_NULL_FIXES {
|
||||
let (msg, pos) = &last;
|
||||
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
|
||||
if !number {
|
||||
if !is_null_number(msg) {
|
||||
break;
|
||||
}
|
||||
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
|
||||
@@ -374,6 +411,11 @@ fn parse_manifest(json: &[u8]) -> Result<Manifest> {
|
||||
Err(FormatError::Damaged(last.0))
|
||||
}
|
||||
|
||||
/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
|
||||
fn is_null_number(msg: &str) -> bool {
|
||||
msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
|
||||
}
|
||||
|
||||
/// Byte offset of a 1-based `line`:`column`.
|
||||
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
@@ -453,6 +495,9 @@ fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
|
||||
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
|
||||
r.blur = effect_size(r.blur, MAX_REFLECTION);
|
||||
r.dist = effect_size(r.dist, MAX_REFLECTION);
|
||||
if r.align.as_deref().is_some_and(|v| !matches!(v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) {
|
||||
r.align = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run properties a list style hands down to the text that inherits it.
|
||||
|
||||
+160
-4
@@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() {
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_run_language_survives_two_native_saves_and_absence() {
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody {
|
||||
paragraphs: vec![Paragraph {
|
||||
runs: vec![
|
||||
Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }),
|
||||
Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let mut q = load(&save(&p).expect("save")).expect("load");
|
||||
for _ in 0..2 {
|
||||
let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs;
|
||||
assert_eq!(runs[0].props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR"));
|
||||
assert_eq!(runs[1].props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(runs[1].props.alt_lang, None);
|
||||
q = load(&save(&q).expect("save again")).expect("load again");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_keeps_chart_relationships_and_payloads() {
|
||||
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
|
||||
@@ -48,6 +73,38 @@ fn native_save_keeps_chart_relationships_and_payloads() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_table_direction_and_column_text_survive_two_saves() {
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
for rtl in [Some(true), Some(false), None] {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut table = Table::new(1, 2, 200.0, 30.0);
|
||||
table.rtl = rtl;
|
||||
table.rows[0].cells[0] = Cell::new("Left column");
|
||||
table.rows[0].cells[1] = Cell::new("Right column");
|
||||
let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
|
||||
let mut q = load(&save(&p).expect("save")).expect("load");
|
||||
for _ in 0..2 {
|
||||
let table = q.slides[0]
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.kind {
|
||||
ShapeKind::Table(table) => Some(table),
|
||||
_ => None,
|
||||
})
|
||||
.expect("table shape");
|
||||
assert_eq!(table.rtl, rtl);
|
||||
assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column");
|
||||
assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column");
|
||||
q = load(&save(&q).expect("save again")).expect("load again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same package without the entries `drop` names.
|
||||
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
@@ -93,9 +150,60 @@ fn media_budget_is_cumulative() {
|
||||
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
|
||||
let bytes = save(&p).unwrap();
|
||||
// Exactly at the limit opens; one byte under is refused, though each item fits.
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
|
||||
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
|
||||
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
|
||||
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_media_is_charged_for_what_it_inflated() {
|
||||
let mut p = Presentation::default();
|
||||
p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
|
||||
p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
|
||||
let mut bytes = save(&p).unwrap();
|
||||
// Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
|
||||
for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
|
||||
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
|
||||
bytes[at] ^= 0xff;
|
||||
}
|
||||
// Each member fits the budget on its own; the second must not reuse what the first spent.
|
||||
let result = load_within(&bytes, Budget::media(20, 2));
|
||||
assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
|
||||
// With room for both, the file opens and reports both.
|
||||
let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
|
||||
assert_eq!(damaged.len(), 2, "{damaged:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_manifest_is_refused_before_inflating() {
|
||||
let bytes = save(&Presentation::default()).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let size = z.by_name("presentation.json").unwrap().size();
|
||||
assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
|
||||
let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_slides_on_missing_layouts_are_repaired() {
|
||||
let mut p = Presentation::default();
|
||||
let template = p.slides[0].clone();
|
||||
let good = template.layout;
|
||||
p.slides = (0..2000u32)
|
||||
.map(|i| {
|
||||
let mut s = (*template).clone();
|
||||
s.id = deckcraft_model::SlideId(10_000 + i);
|
||||
if i % 2 == 1 {
|
||||
s.layout = deckcraft_model::LayoutId(900_000 + i);
|
||||
}
|
||||
Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
p.next_id = 20_000;
|
||||
repair(&mut p);
|
||||
assert!(p.validate().is_empty(), "{:?}", p.validate().first());
|
||||
assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
|
||||
assert_eq!(p.slides[0].layout, good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,7 +232,7 @@ fn repeated_media_ids_are_read_once() {
|
||||
}
|
||||
let bytes = out.finish().unwrap().into_inner();
|
||||
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
|
||||
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
|
||||
let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0;
|
||||
assert_eq!(q.media.len(), 1);
|
||||
assert_eq!(q.media[0].data.len(), 1000);
|
||||
// And the deck saves again.
|
||||
@@ -208,6 +316,54 @@ fn a_file_saved_with_non_finite_numbers_reopens() {
|
||||
assert_eq!(load(&save(&back).unwrap()).unwrap(), back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_transform_survives_two_native_saves_and_absent_fields_stay_absent() {
|
||||
use deckcraft_model::style::Reflection;
|
||||
use deckcraft_model::{Effects, Shape};
|
||||
let mut p = Presentation::default();
|
||||
let reflection = Reflection {
|
||||
blur: 1.0,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.75,
|
||||
dist: 2.0,
|
||||
st_pos: Some(20_000),
|
||||
fade_dir: Some(0),
|
||||
sx: Some(75_000),
|
||||
sy: Some(-50_000),
|
||||
kx: Some(600_000),
|
||||
align: Some("bl".into()),
|
||||
rotate_with_shape: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let shape = Shape { effects: Some(Effects { reflection: Some(reflection.clone()), ..Default::default() }), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
|
||||
let once = load(&save(&p).expect("save")).expect("load");
|
||||
let twice = load(&save(&once).expect("save again")).expect("load again");
|
||||
assert_eq!(twice.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&reflection));
|
||||
|
||||
let absent = Reflection {
|
||||
blur: 1.0,
|
||||
start_alpha: 1.0,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 1.0,
|
||||
dist: 0.0,
|
||||
st_pos: None,
|
||||
dir: None,
|
||||
fade_dir: None,
|
||||
sx: None,
|
||||
sy: None,
|
||||
kx: None,
|
||||
ky: None,
|
||||
align: None,
|
||||
rotate_with_shape: None,
|
||||
};
|
||||
let shape = Shape { effects: Some(Effects { reflection: Some(absent.clone()), ..Default::default() }), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
|
||||
let reopened = load(&save(&p).expect("save absent values")).expect("load absent values");
|
||||
assert_eq!(reopened.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&absent));
|
||||
}
|
||||
|
||||
/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too,
|
||||
/// including a `null` inside a tagged enum, where serde reports only the enclosing object.
|
||||
#[test]
|
||||
|
||||
+22
-3
@@ -71,7 +71,10 @@ impl Unit {
|
||||
/// `2.08"` style text for a length in points.
|
||||
pub fn format(self, pt: f64) -> String {
|
||||
let v = pt / self.points_per_unit();
|
||||
let s = format!("{v:.2}");
|
||||
let mut s = format!("{v:.2}");
|
||||
if s.starts_with('-') && s[1..].bytes().all(|b| b == b'0' || b == b'.') {
|
||||
s.remove(0);
|
||||
}
|
||||
format!("{s}{}", self.suffix())
|
||||
}
|
||||
/// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
|
||||
@@ -153,8 +156,8 @@ impl Xfrm {
|
||||
}
|
||||
/// Rotation normalised to 0..360.
|
||||
pub fn rot_norm(&self) -> f64 {
|
||||
let r = self.rot % 360.0;
|
||||
if r < 0.0 { r + 360.0 } else { r }
|
||||
let r = self.rot.rem_euclid(360.0);
|
||||
if r >= 360.0 { 0.0 } else { r }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +225,14 @@ pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_has_no_negative_zero() {
|
||||
assert_eq!(Unit::Points.format(-0.001), "0.00 pt");
|
||||
assert_eq!(Unit::Inches.format(-0.001), "0.00\"");
|
||||
assert!(!Unit::Centimeters.format(-0.01).starts_with('-'));
|
||||
assert_eq!(Unit::Points.format(-1.5), "-1.50 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emu_roundtrip_is_exact() {
|
||||
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
|
||||
@@ -266,6 +277,14 @@ mod tests {
|
||||
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rot_norm_stays_below_360() {
|
||||
for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] {
|
||||
let n = Xfrm { rot, ..Default::default() }.rot_norm();
|
||||
assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_distance() {
|
||||
assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9);
|
||||
|
||||
@@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
|
||||
out.extend([p1, p2, p3]);
|
||||
}
|
||||
|
||||
/// A piece length ordered totally, so it can sit in a heap.
|
||||
#[derive(PartialEq)]
|
||||
struct Share(f64);
|
||||
impl Eq for Share {}
|
||||
impl PartialOrd for Share {
|
||||
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(o))
|
||||
}
|
||||
}
|
||||
impl Ord for Share {
|
||||
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
|
||||
self.0.total_cmp(&o.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
|
||||
/// splits go to the longest pieces, so a long side gets more points than a short one.
|
||||
fn refine(c: &Contour, n: usize) -> Contour {
|
||||
@@ -136,11 +151,15 @@ fn refine(c: &Contour, n: usize) -> Contour {
|
||||
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
|
||||
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
|
||||
let mut parts = vec![1usize; curves.len()];
|
||||
// The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps
|
||||
// this O(n log n) instead of rescanning every curve for each split.
|
||||
let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse<usize>)> =
|
||||
len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
|
||||
for _ in 0..n - have {
|
||||
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
|
||||
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
|
||||
let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
|
||||
if let Some(p) = parts.get_mut(best) {
|
||||
*p += 1;
|
||||
heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best)));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(1 + 3 * n);
|
||||
@@ -420,6 +439,16 @@ mod tests {
|
||||
assert_eq!(contours(&to_path(&cs)), cs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_many_curves_finishes_at_the_asked_count() {
|
||||
let mut c = Contour { closed: false, pts: vec![Point::ZERO] };
|
||||
for i in 1..=20_000 {
|
||||
line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7)));
|
||||
}
|
||||
let r = refine(&c, 40_000);
|
||||
assert_eq!(r.curves(), 40_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
|
||||
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
|
||||
|
||||
+19
-9
@@ -928,11 +928,25 @@ impl Warp {
|
||||
k_fill = 1.0;
|
||||
}
|
||||
zoom = zoom.max(k_fill);
|
||||
// Per band, measured once: its widest line and its first and last lines' baselines.
|
||||
let mut stats: Vec<Option<(f64, f64, f64)>> = vec![None; nb];
|
||||
for (l, &b) in lines.iter().zip(&band_of) {
|
||||
let w = l.extent.1 - l.extent.0;
|
||||
if let Some(slot) = stats.get_mut(b) {
|
||||
match slot {
|
||||
Some((widest, _, last)) => {
|
||||
*widest = widest.max(w);
|
||||
*last = l.ref_y;
|
||||
}
|
||||
None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut maps = Vec::with_capacity(n);
|
||||
let mut offsets = Vec::new();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let b = band_of.get(i).copied().unwrap_or(0);
|
||||
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
|
||||
let band = stats.get(b).copied().flatten();
|
||||
match self.bands.get(b) {
|
||||
Some(Band::Fill { .. }) | None => {
|
||||
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
|
||||
@@ -941,16 +955,12 @@ impl Warp {
|
||||
}
|
||||
Some(Band::Follow { path, anchor }) => {
|
||||
let len = path.length();
|
||||
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
|
||||
let widest = band.map_or(0.0, |s| s.0);
|
||||
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
|
||||
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
|
||||
let ref_y = match anchor {
|
||||
LineAnchor::Last => members.last().map(|l| l.ref_y),
|
||||
LineAnchor::First => members.first().map(|l| l.ref_y),
|
||||
LineAnchor::Middle => match (members.first(), members.last()) {
|
||||
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
|
||||
_ => None,
|
||||
},
|
||||
LineAnchor::Last => band.map(|s| s.2),
|
||||
LineAnchor::First => band.map(|s| s.1),
|
||||
LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0),
|
||||
}
|
||||
.unwrap_or(l.ref_y);
|
||||
// Lines off the guide run on a parallel curve at their natural length.
|
||||
|
||||
@@ -319,3 +319,20 @@ fn arch_and_inflate_handles_follow_the_pointer() {
|
||||
assert!(more > adjust_defaults("textInflate")[0], "{more}");
|
||||
assert!((more - 20_000.0).abs() < 1.0, "{more}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() {
|
||||
// 50,000 lines on an arch: each line's scale and anchor come from the band measured once.
|
||||
let w = warp("textArchUp", &[], rect()).unwrap();
|
||||
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
|
||||
let lines: Vec<WarpLine> = (0..50_000)
|
||||
.map(|i| {
|
||||
let y = i as f64 * 0.01;
|
||||
WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 }
|
||||
})
|
||||
.collect();
|
||||
let m = w.mapper(&lines, inner);
|
||||
// The last line sits on the arc, as with two lines.
|
||||
let last = lines.len() - 1;
|
||||
assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05);
|
||||
}
|
||||
|
||||
+46
-34
@@ -216,26 +216,37 @@ impl Decoder {
|
||||
pub fn configure_avcc(&mut self, avcc: &[u8]) -> Result<()> {
|
||||
ensure!(avcc.len() >= 7, "avcC too short");
|
||||
ensure!(avcc[0] == 1, "unsupported avcC version {}", avcc[0]);
|
||||
self.nal_length_size = Some((avcc[4] & 3) as usize + 1);
|
||||
let mut pos = 5;
|
||||
let read_sets = |pos: &mut usize, count: usize, d: &mut Decoder| -> Result<()> {
|
||||
let mut sets = Vec::new();
|
||||
let read_sets = |pos: &mut usize, count: usize, sets: &mut Vec<(usize, usize)>| -> Result<()> {
|
||||
for _ in 0..count {
|
||||
ensure!(*pos + 2 <= avcc.len(), "avcC truncated");
|
||||
let len = u16::from_be_bytes([avcc[*pos], avcc[*pos + 1]]) as usize;
|
||||
*pos += 2;
|
||||
ensure!(*pos + len <= avcc.len(), "avcC truncated");
|
||||
d.handle_nal(&avcc[*pos..*pos + len], 0)?;
|
||||
sets.push((*pos, len));
|
||||
*pos += len;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let nsps = (avcc[pos] & 0x1f) as usize;
|
||||
pos += 1;
|
||||
read_sets(&mut pos, nsps, self)?;
|
||||
read_sets(&mut pos, nsps, &mut sets)?;
|
||||
ensure!(pos < avcc.len(), "avcC truncated");
|
||||
let npps = avcc[pos] as usize;
|
||||
pos += 1;
|
||||
read_sets(&mut pos, npps, self)
|
||||
read_sets(&mut pos, npps, &mut sets)?;
|
||||
for (i, (start, len)) in sets.iter().enumerate() {
|
||||
let nal = &avcc[*start..*start + *len];
|
||||
let header = nal.first().copied().ok_or_else(|| Error::Invalid("empty avcC parameter set".into()))?;
|
||||
let expected = if i < nsps { nal_type::SPS } else { nal_type::PPS };
|
||||
ensure!(header & 0x1f == expected, "avcC parameter set has NAL type {}, expected {expected}", header & 0x1f);
|
||||
}
|
||||
for (start, len) in sets {
|
||||
self.handle_nal(&avcc[start..start + len], 0)?;
|
||||
}
|
||||
self.nal_length_size = Some((avcc[4] & 3) as usize + 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Length of NAL length prefixes (from avcC), or None for Annex-B input.
|
||||
@@ -557,16 +568,16 @@ impl Decoder {
|
||||
|
||||
/// Decode all slices of one picture and publish its rows.
|
||||
fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool) {
|
||||
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
|
||||
let mut pic = match reuse {
|
||||
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
|
||||
p.reset(frame.clone());
|
||||
p
|
||||
}
|
||||
_ => PicState::new(frame.clone()),
|
||||
};
|
||||
pic.skip_deblock = draft;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
|
||||
let mut pic = match reuse {
|
||||
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
|
||||
p.reset(frame.clone());
|
||||
p
|
||||
}
|
||||
_ => PicState::new(frame.clone()),
|
||||
};
|
||||
pic.skip_deblock = draft;
|
||||
let mut first_err = None;
|
||||
for s in &slices {
|
||||
if let Err(e) = decode_slice(s, &mut pic) {
|
||||
@@ -574,10 +585,10 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool)
|
||||
}
|
||||
}
|
||||
pic.flush_rows(true);
|
||||
first_err
|
||||
(pic, first_err)
|
||||
}));
|
||||
let err = match result {
|
||||
Ok(e) => e,
|
||||
let (pic, err) = match result {
|
||||
Ok((pic, err)) => (Some(pic), err),
|
||||
Err(_) => {
|
||||
// Make sure readers never block on this frame.
|
||||
let fallback = Planes::gray(frame.width, frame.height);
|
||||
@@ -587,34 +598,35 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool)
|
||||
frame.publish(r, Frame::make_row(&fallback, r, &blank, &|_, _, _| u32::MAX));
|
||||
}
|
||||
}
|
||||
pic = PicState::new(frame.clone());
|
||||
Some(Error::Invalid("internal error while decoding a picture".into()))
|
||||
(None, Some(Error::Invalid("internal error while decoding a picture".into())))
|
||||
}
|
||||
};
|
||||
if let Ok(mut s) = shared.stats.lock() {
|
||||
s.pictures += 1;
|
||||
s.draft_pictures += draft as u64;
|
||||
for st in &pic.mbs {
|
||||
match st.kind {
|
||||
MbKind::I4x4 => s.mb_i4x4 += 1,
|
||||
MbKind::I8x8 => s.mb_i8x8 += 1,
|
||||
MbKind::I16x16 => s.mb_i16x16 += 1,
|
||||
MbKind::IPcm => s.mb_pcm += 1,
|
||||
MbKind::PSkip => s.mb_p_skip += 1,
|
||||
MbKind::BSkip => s.mb_b_skip += 1,
|
||||
MbKind::BDirect16x16 => s.mb_b_direct16x16 += 1,
|
||||
MbKind::Inter => s.mb_inter += 1,
|
||||
MbKind::None => {}
|
||||
}
|
||||
if st.transform_8x8 && !st.kind.is_intra() {
|
||||
s.mb_inter_8x8_transform += 1;
|
||||
if let Some(pic) = &pic {
|
||||
for st in &pic.mbs {
|
||||
match st.kind {
|
||||
MbKind::I4x4 => s.mb_i4x4 += 1,
|
||||
MbKind::I8x8 => s.mb_i8x8 += 1,
|
||||
MbKind::I16x16 => s.mb_i16x16 += 1,
|
||||
MbKind::IPcm => s.mb_pcm += 1,
|
||||
MbKind::PSkip => s.mb_p_skip += 1,
|
||||
MbKind::BSkip => s.mb_b_skip += 1,
|
||||
MbKind::BDirect16x16 => s.mb_b_direct16x16 += 1,
|
||||
MbKind::Inter => s.mb_inter += 1,
|
||||
MbKind::None => {}
|
||||
}
|
||||
if st.transform_8x8 && !st.kind.is_intra() {
|
||||
s.mb_inter_8x8_transform += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(e), Ok(mut slot)) = (err, shared.error.lock()) {
|
||||
slot.get_or_insert(e);
|
||||
}
|
||||
if let Ok(mut p) = shared.pool.lock()
|
||||
if let (Some(pic), Ok(mut p)) = (pic, shared.pool.lock())
|
||||
&& p.len() < 4
|
||||
{
|
||||
p.push(pic);
|
||||
|
||||
@@ -566,6 +566,19 @@ impl<'a> SliceDecoder<'a> {
|
||||
(refs, mvs, false)
|
||||
}
|
||||
|
||||
/// Temporal direct mvs (8.4.1.2.3) from the co-located mv and `DistScaleFactor` (clamped
|
||||
/// to ±1024 in `SliceDecoder`): mvL0 = clip_mv((mvCol · dsf + 128) >> 8), mvL1 =
|
||||
/// clip_mv(mvL0 − mvCol), both clipped to the spec's motion-vector range −2^15..2^15−1.
|
||||
/// The i32 maths cannot overflow; a truncating `as i16` used to wrap mvL0 past the range,
|
||||
/// after which the mvL1 subtraction overflowed a debug build. mvL1 is scaled from the
|
||||
/// unclamped mvL0 with only the results clipped, as the sibling SoundCraft decoder's
|
||||
/// review established (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767.
|
||||
fn temporal_direct_mvs(dsf: i32, mv_col: [i16; 2]) -> ([i16; 2], [i16; 2]) {
|
||||
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1) as i16;
|
||||
let m0 = mv_col.map(|c| (dsf * i32::from(c) + 128) >> 8);
|
||||
(m0.map(clamp), [clamp(m0[0] - i32::from(mv_col[0])), clamp(m0[1] - i32::from(mv_col[1]))])
|
||||
}
|
||||
|
||||
/// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB.
|
||||
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
|
||||
let spatial = self.sh.direct_spatial_mv_pred;
|
||||
@@ -600,13 +613,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
};
|
||||
let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true));
|
||||
let (mv0, mv1) = if ltr {
|
||||
(mv_col, [0, 0])
|
||||
} else {
|
||||
let s = |c: i16| ((dsf * c as i32 + 128) >> 8) as i16;
|
||||
let m0 = [s(mv_col[0]), s(mv_col[1])];
|
||||
(m0, [m0[0] - mv_col[0], m0[1] - mv_col[1]])
|
||||
};
|
||||
let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) };
|
||||
([ref0, 0], [mv0, mv1])
|
||||
};
|
||||
self.set_motion(x, y, unit, unit, refs, mvs);
|
||||
@@ -1481,3 +1488,33 @@ impl<'a> SliceDecoder<'a> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SliceDecoder;
|
||||
|
||||
/// Temporal direct mvs stay inside the spec's motion-vector range −2^15..2^15−1 with a
|
||||
/// large `DistScaleFactor` and a co-located mv at an i16 extreme: the scale used to
|
||||
/// truncate past the range with `as i16`, and mvL0 − mvCol then overflowed a debug build
|
||||
/// ("attempt to subtract with overflow"). `dsf` 256 is the identity scale.
|
||||
#[test]
|
||||
fn temporal_direct_mvs_at_i16_extremes_stay_in_the_spec_range() {
|
||||
for dsf in [-1024i32, -1, 256, 1023] {
|
||||
for col in [[i16::MIN, i16::MIN], [i16::MAX, i16::MAX], [i16::MIN, i16::MAX]] {
|
||||
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(dsf, col);
|
||||
for mv in [mv0, mv1] {
|
||||
for c in mv {
|
||||
assert!((-(1 << 15)..(1 << 15)).contains(&i32::from(c)), "dsf {dsf}, col {col:?}: {mv:?}");
|
||||
}
|
||||
}
|
||||
if dsf == 256 {
|
||||
assert_eq!((mv0, mv1), (col, [0, 0]), "dsf 256 is the identity scale");
|
||||
}
|
||||
}
|
||||
}
|
||||
// soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results clipped;
|
||||
// deriving it from the clipped mvL0 gave [0, 0] here.
|
||||
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(512, [i16::MAX, 0]);
|
||||
assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,56 @@ fn pps(cabac: bool, weighted: bool) -> Vec<u8> {
|
||||
nal(3, 8, &w.finish())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avcc_accepts_generated_sps_and_pps() {
|
||||
let sps = sps(66, 4, false, PocType::T2);
|
||||
let pps = pps(false, false);
|
||||
let (sps, pps) = (&sps[4..], &pps[4..]);
|
||||
let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1];
|
||||
avcc.extend_from_slice(&(sps.len() as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(sps);
|
||||
avcc.push(1);
|
||||
avcc.extend_from_slice(&(pps.len() as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(pps);
|
||||
assert!(Decoder::from_avcc(&avcc).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
|
||||
let sequence = sps(66, 4, false, PocType::T0);
|
||||
let picture = pps(false, false);
|
||||
let slice = slice_nal(
|
||||
&Pic {
|
||||
idr: true,
|
||||
frame_num: 0,
|
||||
ref_idc: 3,
|
||||
long_term_ref: false,
|
||||
mods: vec![],
|
||||
mmcos: vec![],
|
||||
mbs: vec![Some(vec![128; 384]); NMB],
|
||||
b: false,
|
||||
poc_lsb: Some(0),
|
||||
poc_delta: None,
|
||||
wt: None,
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1];
|
||||
let nal = &sequence[4..];
|
||||
avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(nal);
|
||||
avcc.push(2);
|
||||
avcc.extend_from_slice(&((picture.len() - 4) as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(&picture[4..]);
|
||||
avcc.extend_from_slice(&((slice.len() - 4) as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(&slice[4..]);
|
||||
|
||||
let mut decoder = Decoder::with_threads(1);
|
||||
assert!(decoder.configure_avcc(&avcc).is_err());
|
||||
assert!(decoder.flush().is_empty());
|
||||
assert_eq!(decoder.stats().pictures, 0);
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Pic {
|
||||
idr: bool,
|
||||
|
||||
+58
-26
@@ -160,22 +160,35 @@ impl Decoder {
|
||||
pub fn configure_hvcc(&mut self, hvcc: &[u8]) -> Result<()> {
|
||||
ensure!(hvcc.len() >= 23, "hvcC too short");
|
||||
ensure!(hvcc[0] == 1, "unsupported hvcC version {}", hvcc[0]);
|
||||
self.nal_length_size = Some((hvcc[21] & 3) as usize + 1);
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut pos = 23;
|
||||
for _ in 0..num_arrays {
|
||||
ensure!(pos + 3 <= hvcc.len(), "hvcC truncated");
|
||||
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
|
||||
pos += 3;
|
||||
for _ in 0..n {
|
||||
ensure!(pos + 2 <= hvcc.len(), "hvcC truncated");
|
||||
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
ensure!(pos + len <= hvcc.len(), "hvcC truncated");
|
||||
self.handle_nal(&hvcc[pos..pos + len], 0)?;
|
||||
pos += len;
|
||||
for pass in 0..2 {
|
||||
let mut pos = 23;
|
||||
for _ in 0..num_arrays {
|
||||
ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 3, "hvcC truncated");
|
||||
let array_type = hvcc[pos] & 0x3f;
|
||||
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
|
||||
pos += 3;
|
||||
for _ in 0..n {
|
||||
ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 2, "hvcC truncated");
|
||||
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
ensure!(len <= hvcc.len() - pos, "hvcC truncated");
|
||||
let nal = &hvcc[pos..pos + len];
|
||||
if pass == 0 {
|
||||
let header = NalHeader::parse(nal)?;
|
||||
ensure!(
|
||||
[nal_type::VPS, nal_type::SPS, nal_type::PPS, nal_type::PREFIX_SEI, nal_type::SUFFIX_SEI].contains(&array_type),
|
||||
"hvcC array has unsupported NAL type {array_type}"
|
||||
);
|
||||
ensure!(header.nal_type == array_type, "hvcC array type {array_type} disagrees with NAL header type {}", header.nal_type);
|
||||
} else {
|
||||
self.handle_nal(nal, 0)?;
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.nal_length_size = Some((hvcc[21] & 3) as usize + 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -561,9 +574,9 @@ fn run_job(
|
||||
draft: bool,
|
||||
shared: &Shared,
|
||||
) {
|
||||
let mut pic = PicState::new(frame.clone(), sps, pps, layout, scaling);
|
||||
pic.draft = draft;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut pic = PicState::new(frame.clone(), sps, pps, layout, scaling);
|
||||
pic.draft = draft;
|
||||
let mut first_err = None;
|
||||
for s in &slices {
|
||||
if let Err(e) = SliceDecoder::decode(&mut pic, s) {
|
||||
@@ -571,10 +584,10 @@ fn run_job(
|
||||
}
|
||||
}
|
||||
crate::filter::finish(&mut pic);
|
||||
first_err
|
||||
(pic, first_err)
|
||||
}));
|
||||
let err = match result {
|
||||
Ok(e) => e,
|
||||
let (pic, err) = match result {
|
||||
Ok((pic, err)) => (Some(pic), err),
|
||||
Err(_) => {
|
||||
// make sure readers never block on this frame
|
||||
let gray = Frame::gray(0, frame.poc, &frame);
|
||||
@@ -584,18 +597,20 @@ fn run_job(
|
||||
frame.publish(r, crate::picture::FrameRow { y: row.y.clone(), cb: row.cb.clone(), cr: row.cr.clone(), col: row.col.clone() });
|
||||
}
|
||||
}
|
||||
Some(Error::Invalid("internal error while decoding a picture".into()))
|
||||
(None, Some(Error::Invalid("internal error while decoding a picture".into())))
|
||||
}
|
||||
};
|
||||
if let Ok(mut s) = shared.stats.lock() {
|
||||
s.pictures += 1;
|
||||
for b in &pic.blk {
|
||||
if b.flags & F_INTRA != 0 {
|
||||
s.intra_4x4_blocks += 1;
|
||||
} else if b.flags & F_SKIP != 0 {
|
||||
s.skip_4x4_blocks += 1;
|
||||
} else if b.flags != 0 {
|
||||
s.inter_4x4_blocks += 1;
|
||||
if let Some(pic) = &pic {
|
||||
for b in &pic.blk {
|
||||
if b.flags & F_INTRA != 0 {
|
||||
s.intra_4x4_blocks += 1;
|
||||
} else if b.flags & F_SKIP != 0 {
|
||||
s.skip_4x4_blocks += 1;
|
||||
} else if b.flags != 0 {
|
||||
s.inter_4x4_blocks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,3 +666,20 @@ fn make_picture(o: &Output) -> Picture {
|
||||
draft: o.meta.draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::Decoder;
|
||||
|
||||
#[test]
|
||||
fn hvcc_skips_prefix_and_suffix_sei_arrays() {
|
||||
let mut config = vec![0; 23];
|
||||
config[0] = 1;
|
||||
config[22] = 2;
|
||||
config.extend_from_slice(&[39, 0, 1, 0, 2, 0x4e, 0x01]);
|
||||
config.extend_from_slice(&[40, 0, 1, 0, 2, 0x50, 0x01]);
|
||||
|
||||
let decoder = Decoder::from_hvcc(&config);
|
||||
assert!(decoder.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,9 @@ impl SliceHeader {
|
||||
|
||||
/// entry_point_offset list (tiles / wavefront substreams).
|
||||
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
|
||||
self.entry_points.clear();
|
||||
// A fresh list, not `clear()`: a dependent segment starts as a copy of its independent
|
||||
// segment's header, and clearing would keep that copy's allocation for every segment.
|
||||
self.entry_points = Vec::new();
|
||||
if pps.tiles_enabled || pps.entropy_coding_sync {
|
||||
let n = r.read_ue()?;
|
||||
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
|
||||
|
||||
@@ -644,3 +644,62 @@ fn picture_store_is_checked_against_the_memory_budget() {
|
||||
assert!(!decodes_within(&stream, pictures * one - 1));
|
||||
assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_accepts_generated_vps_sps_and_pps() {
|
||||
let sets = [vps(), sps(true), pps(0, &None, false)];
|
||||
let mut hvcc = vec![0; 23];
|
||||
hvcc[0] = 1;
|
||||
hvcc[22] = sets.len() as u8;
|
||||
for (kind, set) in [32, 33, 34].into_iter().zip(sets) {
|
||||
let nal = &set[4..];
|
||||
hvcc.extend_from_slice(&[kind, 0, 1]);
|
||||
hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(nal);
|
||||
}
|
||||
assert!(Decoder::from_hvcc(&hvcc).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
|
||||
let sets = [vps(), sps(true), pps(0, &None, false)];
|
||||
let mut stream = Vec::new();
|
||||
let mut saved = None;
|
||||
encode_picture(
|
||||
&PicDesc {
|
||||
nal_type: 19,
|
||||
poc: 0,
|
||||
intra: true,
|
||||
cu: Cu::Pcm,
|
||||
pps_id: 0,
|
||||
st: vec![],
|
||||
lt: vec![],
|
||||
num_ref: 0,
|
||||
list_entry: None,
|
||||
segments: whole(WC * HC),
|
||||
},
|
||||
&None,
|
||||
&content(0),
|
||||
&mut stream,
|
||||
&mut saved,
|
||||
);
|
||||
|
||||
let mut hvcc = vec![0; 23];
|
||||
hvcc[0] = 1;
|
||||
hvcc[22] = 4;
|
||||
for (kind, set) in [32, 33, 34].into_iter().zip(sets) {
|
||||
let nal = &set[4..];
|
||||
hvcc.extend_from_slice(&[kind, 0, 1]);
|
||||
hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(nal);
|
||||
}
|
||||
let slice = &stream[4..];
|
||||
hvcc.extend_from_slice(&[19, 0, 1]);
|
||||
hvcc.extend_from_slice(&(slice.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(slice);
|
||||
|
||||
let mut decoder = Decoder::with_threads(1);
|
||||
assert!(decoder.configure_hvcc(&hvcc).is_err());
|
||||
assert!(decoder.flush().is_empty());
|
||||
assert_eq!(decoder.stats().pictures, 0);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -17,6 +17,8 @@ const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// Keep request frames in step with the connected backend's reply limit.
|
||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone). Coordinates are points on the \
|
||||
current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \
|
||||
@@ -62,9 +64,16 @@ impl Server {
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
for line in input.lines() {
|
||||
let line = line?;
|
||||
if let Some(reply) = self.handle_line(&line) {
|
||||
let mut input = input;
|
||||
while let Some(frame) = read_frame(&mut input)? {
|
||||
let reply = match frame {
|
||||
Frame::Complete(bytes) => match std::str::from_utf8(&bytes) {
|
||||
Ok(line) => self.handle_line(line),
|
||||
Err(_) => Some(error(Value::Null, PARSE_ERROR, "request frame is not valid UTF-8").to_string()),
|
||||
},
|
||||
Frame::TooLarge => Some(error(Value::Null, INVALID_REQUEST, "request frame exceeds 16 MiB").to_string()),
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
@@ -172,3 +181,38 @@ impl Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Frame {
|
||||
Complete(Vec<u8>),
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// Read through one newline while retaining no more than the documented frame budget.
|
||||
fn read_frame(input: &mut impl BufRead) -> io::Result<Option<Frame>> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut too_large = false;
|
||||
loop {
|
||||
let available = input.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
if bytes.is_empty() && !too_large {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) }));
|
||||
}
|
||||
let count = available.iter().position(|byte| *byte == b'\n').map_or(available.len(), |i| i + 1);
|
||||
if !too_large {
|
||||
let remaining = MAX_FRAME_BYTES.saturating_sub(bytes.len());
|
||||
if count > remaining {
|
||||
too_large = true;
|
||||
bytes.clear();
|
||||
} else {
|
||||
bytes.extend_from_slice(&available[..count]);
|
||||
}
|
||||
}
|
||||
let ended = available[count - 1] == b'\n';
|
||||
input.consume(count);
|
||||
if ended {
|
||||
return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,34 @@ fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||||
serde_json::from_str(&out).expect("json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_stdio_frame_is_drained_before_the_next_request() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let mut input = vec![b'x'; 16 * 1024 * 1024 + 1];
|
||||
input.extend_from_slice(b"\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n");
|
||||
let mut output = Vec::new();
|
||||
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
|
||||
let replies: Vec<Value> = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[0]["error"]["code"], -32600);
|
||||
assert!(replies[0]["error"]["message"].as_str().unwrap().contains("16 MiB"));
|
||||
assert_eq!(replies[1]["id"], 1);
|
||||
assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_stdio_frame_gets_a_parse_error_and_does_not_stop_serving() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let mut input = b"\xff\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n".to_vec();
|
||||
let mut output = Vec::new();
|
||||
s.serve(std::io::Cursor::new(&mut input), &mut output).unwrap();
|
||||
let replies: Vec<Value> = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[0]["error"]["code"], -32700);
|
||||
assert!(replies[0]["error"]["message"].as_str().unwrap().contains("UTF-8"));
|
||||
assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft");
|
||||
}
|
||||
|
||||
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
|
||||
let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args}));
|
||||
let res = &r["result"];
|
||||
@@ -34,6 +62,40 @@ fn handshake_and_tool_list() {
|
||||
assert!(s.handle_line("{}").is_some());
|
||||
}
|
||||
|
||||
/// #1151: `insert_chart` takes x, y, w and h like `insert_picture` and `insert_table`.
|
||||
#[test]
|
||||
fn insert_chart_takes_a_box() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
tool(&mut s, "new_presentation", json!({"theme": "Harbor"}));
|
||||
let chart = json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]});
|
||||
let box_of = |s: &mut Server, id: u64| {
|
||||
let sl = tool(s, "inspect_slide", json!({}));
|
||||
let shape = sl["shapes"].as_array().unwrap().iter().find(|x| x["id"].as_u64() == Some(id)).unwrap().clone();
|
||||
assert_eq!(shape["kind"], "chart");
|
||||
shape["box"].clone()
|
||||
};
|
||||
let mut args = chart.clone();
|
||||
for (k, v) in [("x", 50), ("y", 60), ("w", 300), ("h", 200)] {
|
||||
args[k] = json!(v);
|
||||
}
|
||||
let placed = tool(&mut s, "insert_chart", args)["id"].as_u64().unwrap();
|
||||
assert_eq!(box_of(&mut s, placed), json!([50.0, 60.0, 300.0, 200.0]));
|
||||
// without a full box the chart keeps its default place, as for pictures and tables
|
||||
let plain = tool(&mut s, "insert_chart", chart.clone())["id"].as_u64().unwrap();
|
||||
let default_box = box_of(&mut s, plain);
|
||||
let mut partial = chart.clone();
|
||||
partial["x"] = json!(50);
|
||||
let partial_id = tool(&mut s, "insert_chart", partial)["id"].as_u64().unwrap();
|
||||
assert_eq!(box_of(&mut s, partial_id), default_box);
|
||||
// a hostile box is answered, not a panic
|
||||
let mut odd = chart;
|
||||
for (k, v) in [("x", json!(-1e300)), ("y", json!(1e300)), ("w", json!(-5)), ("h", json!(0))] {
|
||||
odd[k] = v;
|
||||
}
|
||||
let r = call(&mut s, 11, "tools/call", json!({"name": "insert_chart", "arguments": odd}));
|
||||
assert!(r["result"].is_object(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_builds_a_deck() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
|
||||
@@ -234,9 +234,9 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"insert_chart",
|
||||
"Insert chart",
|
||||
"Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series.",
|
||||
"Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series, optionally at a box.",
|
||||
obj(
|
||||
json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}}),
|
||||
json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}, "x": num("Left"), "y": num("Top"), "w": num("Width"), "h": num("Height")}),
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
@@ -444,7 +444,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
}
|
||||
wrap(exec(b, "insert.table", p))
|
||||
}
|
||||
"insert_chart" => wrap(exec(b, "insert.chart", a)),
|
||||
"insert_chart" => {
|
||||
let mut p = a.clone();
|
||||
if let (Some(x), Some(y), Some(w), Some(h)) = (f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")) {
|
||||
p["rect"] = json!([x, y, w, h]);
|
||||
}
|
||||
wrap(exec(b, "insert.chart", p))
|
||||
}
|
||||
"select" => wrap(exec(b, "edit.select", a)),
|
||||
"pointer" => wrap(b.call("ui.pointer", a)),
|
||||
"select_tool" => wrap(b.call("ui.tool.select", a)),
|
||||
|
||||
@@ -31,7 +31,9 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap();
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
let read = read.then(|| in_dir.to_path_buf()).into_iter().collect::<Vec<_>>();
|
||||
let write = write.then(|| out_dir.to_path_buf()).into_iter().collect::<Vec<_>>();
|
||||
let roots = AutomationRoots::new(&read, &write).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
|
||||
ok(&mut server, "new_presentation", json!({}));
|
||||
(server, base)
|
||||
@@ -215,7 +217,7 @@ fn without_roots_nothing_is_confined() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("a.txt"), "Anywhere").unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).unwrap())));
|
||||
ok(&mut server, "open_presentation", json!({"path": s(&base.join("a.txt"))}));
|
||||
ok(&mut server, "save_presentation", json!({"path": s(&base.join("b.deckcraft"))}));
|
||||
assert!(base.join("b.deckcraft").exists());
|
||||
|
||||
@@ -50,6 +50,8 @@ const MAX_CHARS: usize = 32_768;
|
||||
const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Nesting depth of region nodes.
|
||||
const MAX_REGION_DEPTH: usize = 32;
|
||||
/// Region nodes and path points parsed together, including nodes in sibling branches.
|
||||
const MAX_REGION_WORK: usize = MAX_POINTS;
|
||||
|
||||
const TA_RIGHT: u32 = 2;
|
||||
const TA_CENTER: u32 = 6;
|
||||
@@ -93,7 +95,7 @@ impl Fill {
|
||||
if w == 0.0 || h == 0.0 {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) {
|
||||
if *wrap == 4 && !((0.0..w).contains(&p.0) && (0.0..h).contains(&p.1)) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let x = (p.0.rem_euclid(w) as usize).min(img.w - 1);
|
||||
@@ -1106,12 +1108,14 @@ fn path(r: Bytes) -> Option<Vec<Op>> {
|
||||
/// A region object as a convex area in world units (non-convex parts become their bounding
|
||||
/// rectangle).
|
||||
fn region(r: Bytes) -> Option<Obj> {
|
||||
region_node(r, 8, 0).map(|(a, _)| Obj::Region(a))
|
||||
let mut work = 0;
|
||||
region_node(r, 8, 0, &mut work).map(|(a, _)| Obj::Region(a))
|
||||
}
|
||||
|
||||
/// One region node at `off`: its area and its length in bytes.
|
||||
fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> {
|
||||
if depth > MAX_REGION_DEPTH {
|
||||
fn region_node(r: Bytes, off: usize, depth: usize, work: &mut usize) -> Option<(Area, usize)> {
|
||||
*work = work.checked_add(1)?;
|
||||
if depth > MAX_REGION_DEPTH || *work > MAX_REGION_WORK {
|
||||
return None;
|
||||
}
|
||||
match r.u32(off)? {
|
||||
@@ -1121,14 +1125,23 @@ fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> {
|
||||
}
|
||||
0x1000_0001 => {
|
||||
let size = r.u32(off + 4)? as usize;
|
||||
let area = path(r.tail(off + 8)?).map(|ops| area_of_ops(&ops)).unwrap_or_default();
|
||||
if size < 12 {
|
||||
return None;
|
||||
}
|
||||
let data = Bytes(r.slice(off + 8, size)?);
|
||||
let points = (data.u32(4)? as usize).min(MAX_POINTS);
|
||||
*work = work.checked_add(points)?;
|
||||
if *work > MAX_REGION_WORK {
|
||||
return None;
|
||||
}
|
||||
let area = area_of_ops(&path(data)?);
|
||||
Some((Some(area), 8usize.checked_add(size)?))
|
||||
}
|
||||
0x1000_0002 => Some((Some(Vec::new()), 4)),
|
||||
0x1000_0003 => Some((None, 4)),
|
||||
mode @ 0..=5 => {
|
||||
let (a, la) = region_node(r, off + 4, depth + 1)?;
|
||||
let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?;
|
||||
let (a, la) = region_node(r, off + 4, depth + 1, work)?;
|
||||
let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1, work)?;
|
||||
let out = match mode {
|
||||
0 => b,
|
||||
1 => area_intersect(a, b),
|
||||
|
||||
@@ -1875,6 +1875,34 @@ fn emfplus_region_clip_intersects_rectangles() {
|
||||
assert_eq!((p.width, p.height), (20.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_region_path_cannot_read_into_its_sibling() {
|
||||
let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]);
|
||||
// The path declares only its 12-byte header, but claims three points. The following
|
||||
// rectangle must not supply those points or be parsed twice as the next node.
|
||||
let path = cat(vec![h32(0x1000_0001), h32(12), h32(0), h32(3), h32(0)]);
|
||||
let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), path, rect([10.0, 10.0, 20.0, 20.0])]);
|
||||
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])]));
|
||||
let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]);
|
||||
assert_eq!(p.items.len(), 1);
|
||||
assert_eq!((p.width, p.height), (100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_region_path_keeps_its_shape() {
|
||||
let mut path_data = cat(vec![h32(0), h32(4), h32(0x4000)]);
|
||||
for (x, y) in [(10i16, 10i16), (30, 10), (30, 30), (10, 30)] {
|
||||
path_data.extend(x.to_le_bytes());
|
||||
path_data.extend(y.to_le_bytes());
|
||||
}
|
||||
path_data.extend([0, 1, 1, 0x81]);
|
||||
let path_node = cat(vec![h32(0x1000_0001), h32(path_data.len() as u32), path_data]);
|
||||
let region = cat(vec![h32(0xDBC0_1002), h32(1), path_node]);
|
||||
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])]));
|
||||
let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]);
|
||||
assert_eq!((p.width, p.height), (20.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_pie_and_arc() {
|
||||
// A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100).
|
||||
|
||||
+16
-16
@@ -367,37 +367,37 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
|
||||
("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]),
|
||||
("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]),
|
||||
("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]),
|
||||
("grow", "Grow & Turn", AnimClass::Entrance, 31, 500, &[]),
|
||||
("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]),
|
||||
("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]),
|
||||
("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]),
|
||||
("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]),
|
||||
("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]),
|
||||
("box", "Box", AnimClass::Entrance, 4, 500, &["in", "out"]),
|
||||
("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]),
|
||||
("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]),
|
||||
("circle", "Circle", AnimClass::Entrance, 6, 500, &["in", "out"]),
|
||||
("diamond", "Diamond", AnimClass::Entrance, 8, 500, &["in", "out"]),
|
||||
("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]),
|
||||
("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]),
|
||||
("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]),
|
||||
("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]),
|
||||
("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]),
|
||||
("expand", "Expand", AnimClass::Entrance, 55, 500, &[]),
|
||||
("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]),
|
||||
("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]),
|
||||
("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]),
|
||||
("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]),
|
||||
("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]),
|
||||
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]),
|
||||
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]),
|
||||
("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),
|
||||
("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]),
|
||||
("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]),
|
||||
("desaturate", "Desaturate", AnimClass::Emphasis, 35, 500, &[]),
|
||||
("darken", "Darken", AnimClass::Emphasis, 25, 500, &[]),
|
||||
("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]),
|
||||
("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]),
|
||||
("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]),
|
||||
("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]),
|
||||
("objectColor", "Object Color", AnimClass::Emphasis, 19, 2000, &[]),
|
||||
("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]),
|
||||
("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]),
|
||||
("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]),
|
||||
("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]),
|
||||
("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]),
|
||||
("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]),
|
||||
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 500, &[]),
|
||||
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]),
|
||||
("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]),
|
||||
("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]),
|
||||
("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]),
|
||||
@@ -408,20 +408,20 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
|
||||
("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]),
|
||||
("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]),
|
||||
("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]),
|
||||
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 500, &[]),
|
||||
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]),
|
||||
("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]),
|
||||
("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]),
|
||||
("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]),
|
||||
("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]),
|
||||
("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]),
|
||||
("boxOut", "Box", AnimClass::Exit, 4, 500, &["in", "out"]),
|
||||
("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]),
|
||||
("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]),
|
||||
("diamondOut", "Diamond", AnimClass::Exit, 8, 500, &["in", "out"]),
|
||||
("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]),
|
||||
("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]),
|
||||
("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]),
|
||||
("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]),
|
||||
("spinnerOut", "Spinner", AnimClass::Exit, 49, 1000, &[]),
|
||||
("contract", "Contract", AnimClass::Exit, 55, 500, &[]),
|
||||
("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]),
|
||||
("contract", "Contract", AnimClass::Exit, 55, 1000, &[]),
|
||||
("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]),
|
||||
("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]),
|
||||
("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]),
|
||||
|
||||
+107
-35
@@ -758,17 +758,20 @@ impl Chart {
|
||||
}
|
||||
|
||||
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
let mut hi: f64 = 0.0;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &self.series {
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
// Sums per category, from the values the series hold (an absent one adds nothing).
|
||||
let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]);
|
||||
for s in &self.series {
|
||||
for (i, v) in s.values.iter().enumerate().take(n) {
|
||||
let v = v.unwrap_or(0.0);
|
||||
if v >= 0.0 {
|
||||
pos[i] += v;
|
||||
} else {
|
||||
neg[i] += v;
|
||||
}
|
||||
}
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p));
|
||||
let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m));
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
@@ -1270,7 +1273,14 @@ impl Chart {
|
||||
let mut bins: Vec<Bin> = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect();
|
||||
for x in v {
|
||||
// (lo, hi] bins, the first one also holding the minimum.
|
||||
let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
|
||||
let mut i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
|
||||
// The division can round across an edge: settle on the bin whose stored bounds hold x.
|
||||
while i > 0 && x <= bins[i - 1].hi {
|
||||
i -= 1;
|
||||
}
|
||||
while i + 1 < count && x > bins[i].hi {
|
||||
i += 1;
|
||||
}
|
||||
if let Some(b) = bins.get_mut(i) {
|
||||
b.count += 1;
|
||||
}
|
||||
@@ -1341,40 +1351,50 @@ impl Chart {
|
||||
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
|
||||
/// group without categories) and, per series, each group's summary (None when it has no
|
||||
/// points).
|
||||
///
|
||||
/// Each value is put in its group's bucket once (groups are found by name in a map), and the
|
||||
/// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out.
|
||||
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
|
||||
let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1);
|
||||
let mut groups: Vec<String> = Vec::new();
|
||||
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for c in &self.categories {
|
||||
if !groups.contains(c) {
|
||||
if !index.contains_key(c.as_str()) && groups.len() < max_groups {
|
||||
index.insert(c, groups.len());
|
||||
groups.push(c.clone());
|
||||
}
|
||||
}
|
||||
if groups.is_empty() {
|
||||
groups.push(String::new());
|
||||
index.insert("", 0);
|
||||
}
|
||||
// A value past the categories belongs to the unnamed group, when there is one.
|
||||
let past_end = index.get("").copied();
|
||||
let stats = self
|
||||
.series
|
||||
.iter()
|
||||
.map(|s| {
|
||||
groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let pts: Vec<f64> = s
|
||||
.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
|
||||
.filter_map(|(_, v)| *v)
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
box_stats_by(pts, self.inclusive_quartiles)
|
||||
})
|
||||
.collect()
|
||||
let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); groups.len()];
|
||||
for (i, v) in s.values.iter().enumerate() {
|
||||
let Some(v) = v.filter(|v| v.is_finite()) else { continue };
|
||||
let g = match self.categories.get(i) {
|
||||
Some(c) => index.get(c.as_str()).copied(),
|
||||
None => past_end,
|
||||
};
|
||||
if let Some(b) = g.and_then(|g| buckets.get_mut(g)) {
|
||||
b.push(v);
|
||||
}
|
||||
}
|
||||
buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect()
|
||||
})
|
||||
.collect();
|
||||
(groups, stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most series x group summaries [`Chart::box_groups`] makes.
|
||||
pub const MAX_BOX_CELLS: usize = 1_000_000;
|
||||
|
||||
/// Widens the (min, max) extent `ext` to include `v`.
|
||||
fn widen(ext: &mut (f64, f64), v: f64) {
|
||||
ext.0 = ext.0.min(v);
|
||||
@@ -1447,33 +1467,41 @@ impl Chart {
|
||||
let n = self.categories.len().max(s.values.len());
|
||||
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
|
||||
// Each leaf's path from the top level down, blanks filled from the row above.
|
||||
let mut last: Vec<String> = vec![String::new(); levels.len()];
|
||||
let paths: Vec<Vec<String>> = (0..n)
|
||||
// Labels are shared, not copied: an outer label given once stands for every leaf under it.
|
||||
use std::rc::Rc;
|
||||
let empty: Rc<str> = Rc::from("");
|
||||
let mut last: Vec<Rc<str>> = vec![empty.clone(); levels.len()];
|
||||
let paths: Vec<Vec<Rc<str>>> = (0..n)
|
||||
.map(|i| {
|
||||
let mut path = Vec::with_capacity(levels.len() + 1);
|
||||
for (d, lv) in levels.iter().enumerate().rev() {
|
||||
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
|
||||
if !l.is_empty() {
|
||||
last[d] = l.to_string();
|
||||
last[d] = Rc::from(l);
|
||||
// A new outer label starts fresh inner groups.
|
||||
for inner in last.iter_mut().take(d) {
|
||||
inner.clear();
|
||||
*inner = empty.clone();
|
||||
}
|
||||
}
|
||||
path.push(last[d].clone());
|
||||
}
|
||||
path.push(self.categories.get(i).cloned().unwrap_or_default());
|
||||
path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str())));
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
|
||||
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
|
||||
fn build(rows: &[(usize, &[std::rc::Rc<str>])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
|
||||
let mut out: Vec<TreeNode> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < rows.len() {
|
||||
let (idx, path) = rows[i];
|
||||
if path.len() <= 1 {
|
||||
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
|
||||
out.push(TreeNode {
|
||||
name: path.first().map(|p| p.to_string()).unwrap_or_default(),
|
||||
value: value(idx),
|
||||
leaf: Some(idx),
|
||||
children: vec![],
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1482,14 +1510,14 @@ impl Chart {
|
||||
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
|
||||
j += 1;
|
||||
}
|
||||
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
|
||||
let sub: Vec<(usize, &[std::rc::Rc<str>])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
|
||||
let children = build(&sub, value);
|
||||
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
|
||||
out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
|
||||
let rows: Vec<(usize, &[Rc<str>])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
|
||||
build(&rows, &value)
|
||||
}
|
||||
}
|
||||
@@ -1535,6 +1563,16 @@ mod chartex_tests {
|
||||
assert_eq!(c.histogram_bins().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_value_on_a_bin_edge_counts_in_that_bin() {
|
||||
let mut c = obs(&[0.0, 2.1, 3.0]);
|
||||
c.bin_size = Some(0.3);
|
||||
let bins = c.histogram_bins();
|
||||
let own = bins.iter().find(|b| b.lo < 2.1 && 2.1 <= b.hi).expect("a bin holds 2.1");
|
||||
assert_eq!(own.count, 1, "2.1 must be counted in ({}, {}]", own.lo, own.hi);
|
||||
assert_eq!(bins.iter().map(|b| b.count).sum::<usize>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pareto_view_sorts_bins_and_adds_the_cumulative_line() {
|
||||
let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]);
|
||||
@@ -1590,6 +1628,40 @@ mod chartex_tests {
|
||||
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_box_hierarchy_and_stacked_charts_take_linear_work() {
|
||||
// 50,000 distinct categories in one series: grouped through a map, one pass.
|
||||
let n = 50_000;
|
||||
let mut c = Chart::sample(ChartType::BoxWhisker);
|
||||
c.series.truncate(1);
|
||||
c.categories = (0..n).map(|i| format!("g{i}")).collect();
|
||||
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
|
||||
let (groups, stats) = c.box_groups();
|
||||
assert_eq!((groups.len(), stats[0].len()), (n, n));
|
||||
assert!(stats[0].iter().all(Option::is_some));
|
||||
// 2,000 series cap the groups so the result stays within MAX_BOX_CELLS.
|
||||
c.series = vec![c.series[0].clone(); 2000];
|
||||
let (groups, stats) = c.box_groups();
|
||||
assert_eq!(groups.len(), MAX_BOX_CELLS / 2000);
|
||||
assert!(stats.iter().all(|s| s.len() == groups.len()));
|
||||
// One long outer label over 50,000 leaves is shared, not copied per leaf.
|
||||
let mut t = Chart::sample(ChartType::Treemap);
|
||||
t.series.truncate(1);
|
||||
t.categories = (0..n).map(|i| format!("c{i}")).collect();
|
||||
t.series[0].values = (0..n).map(|_| Some(1.0)).collect();
|
||||
let mut outer = vec![String::new(); n];
|
||||
outer[0] = "o".repeat(1 << 16);
|
||||
t.category_levels = vec![outer];
|
||||
let tree = t.category_tree();
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].children.len(), n);
|
||||
// Stacked sums over many sparse series.
|
||||
let mut st = Chart::sample(ChartType::StackedBar);
|
||||
st.categories = (0..n).map(|i| i.to_string()).collect();
|
||||
st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect();
|
||||
assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_groups_follow_category_labels() {
|
||||
let c = Chart::sample(ChartType::BoxWhisker);
|
||||
|
||||
+169
-22
@@ -78,45 +78,57 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Paragraph::default());
|
||||
}
|
||||
let (mut pi, mut ch) = clamp(body, pos);
|
||||
let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
|
||||
let (pi0, mut ch) = clamp(body, pos);
|
||||
let props = props.unwrap_or_else(|| props_at(body, (pi0, ch)));
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
// Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a
|
||||
// list and the body's vector is spliced once instead of shifted for every `\n`.
|
||||
let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) };
|
||||
let mut cur = std::mem::take(slot);
|
||||
let mut done: Vec<Paragraph> = Vec::new();
|
||||
let mut first = true;
|
||||
for part in text.split('\n') {
|
||||
if !first {
|
||||
// Split paragraph pi at ch.
|
||||
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
||||
let idx = split_at(p, ch);
|
||||
let tail: Vec<Run> = p.runs.drain(idx..).collect();
|
||||
let newp =
|
||||
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() };
|
||||
p.end_props = props.clone();
|
||||
body.paragraphs.insert(pi + 1, newp);
|
||||
pi += 1;
|
||||
// Split the current paragraph at ch.
|
||||
let idx = split_at(&mut cur, ch);
|
||||
let tail: Vec<Run> = cur.runs.drain(idx..).collect();
|
||||
let newp = Paragraph {
|
||||
level: cur.level,
|
||||
props: cur.props.clone(),
|
||||
runs: tail,
|
||||
def_props: cur.def_props.clone(),
|
||||
end_props: cur.end_props.clone(),
|
||||
};
|
||||
cur.end_props = props.clone();
|
||||
done.push(std::mem::replace(&mut cur, newp));
|
||||
ch = 0;
|
||||
}
|
||||
first = false;
|
||||
// The run index just after the last insertion: the next one lands there with no rescan.
|
||||
let mut next: Option<usize> = None;
|
||||
for (k, seg) in part.split('\u{b}').enumerate() {
|
||||
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
||||
if k > 0 {
|
||||
let idx = split_at(p, ch);
|
||||
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
|
||||
cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
ch += 1;
|
||||
next = Some(idx + 1);
|
||||
}
|
||||
if seg.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let idx = split_at(p, ch);
|
||||
p.runs.insert(idx, Run::with(seg, props.clone()));
|
||||
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
|
||||
cur.runs.insert(idx, Run::with(seg, props.clone()));
|
||||
ch += seg.chars().count();
|
||||
next = Some(idx + 1);
|
||||
}
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
p.normalize();
|
||||
if p.runs.is_empty() {
|
||||
p.end_props = props.clone();
|
||||
}
|
||||
cur.normalize();
|
||||
if cur.runs.is_empty() {
|
||||
cur.end_props = props.clone();
|
||||
}
|
||||
}
|
||||
let pi = pi0 + done.len();
|
||||
done.push(cur);
|
||||
body.paragraphs.splice(pi0..=pi0, done);
|
||||
(pi, ch)
|
||||
}
|
||||
|
||||
@@ -252,8 +264,16 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
|
||||
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
|
||||
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
let mut caret = clamp(body, pos);
|
||||
// Plain text runs go straight in at the run index after the previous one and the paragraph is
|
||||
// normalized once, not rescanned and merged for every source run.
|
||||
let mut next: Option<usize> = None;
|
||||
let mut dirty = false;
|
||||
for (k, sp) in src.paragraphs.iter().enumerate() {
|
||||
if k > 0 {
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
(next, dirty) = (None, false);
|
||||
caret = insert(body, caret, "\n", None);
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
p.level = sp.level;
|
||||
@@ -261,6 +281,22 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
}
|
||||
}
|
||||
for r in &sp.runs {
|
||||
if r.kind == RunKind::Text
|
||||
&& !r.text.is_empty()
|
||||
&& !r.text.contains(['\n', '\r', '\u{b}'])
|
||||
&& let Some(p) = body.paragraphs.get_mut(caret.0)
|
||||
{
|
||||
let idx = next.unwrap_or_else(|| split_at(p, caret.1));
|
||||
p.runs.insert(idx, Run::with(&r.text, r.props.clone()));
|
||||
caret.1 += r.text.chars().count();
|
||||
(next, dirty) = (Some(idx + 1), true);
|
||||
continue;
|
||||
}
|
||||
// Anything else takes the general path, after the pending runs are merged.
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
(next, dirty) = (None, false);
|
||||
match r.kind {
|
||||
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
||||
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
||||
@@ -274,6 +310,9 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
caret
|
||||
}
|
||||
|
||||
@@ -418,6 +457,8 @@ fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
|
||||
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
|
||||
/// that shares nothing with the old text replaces it as plain text.
|
||||
pub fn replace_text(body: &mut TextBody, text: &str) {
|
||||
// Normalise line endings first so the common prefix/suffix cannot split a `\r\n` pair.
|
||||
let text = &text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let old: Vec<char> = body.text().chars().collect();
|
||||
let new: Vec<char> = text.chars().collect();
|
||||
if old == new {
|
||||
@@ -467,6 +508,9 @@ pub fn change_case(s: &str, mode: &str) -> String {
|
||||
/// piece is the changed form of the matching input piece, even where a character changes length.
|
||||
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
let mut start = true;
|
||||
// All characters of all pieces, so word-final sigma sees neighbours across piece boundaries.
|
||||
let all: Vec<char> = parts.iter().flat_map(|s| s.chars()).collect();
|
||||
let mut base = 0;
|
||||
parts
|
||||
.iter()
|
||||
.map(|s| match mode {
|
||||
@@ -477,10 +521,14 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
// `title` restarts after whitespace, sentence case after sentence punctuation.
|
||||
let title = mode == "title";
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
for (j, c) in s.chars().enumerate() {
|
||||
let i = base + j;
|
||||
if start && c.is_alphabetic() {
|
||||
out.extend(c.to_uppercase());
|
||||
start = false;
|
||||
} else if c == 'Σ' && i > 0 && all[i - 1].is_alphabetic() && !all.get(i + 1).is_some_and(|n| n.is_alphabetic()) {
|
||||
// Word-final capital sigma lowercases to ς, as `str::to_lowercase` does.
|
||||
out.push('ς');
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
@@ -488,6 +536,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
base += s.chars().count();
|
||||
out
|
||||
}
|
||||
})
|
||||
@@ -502,6 +551,20 @@ mod tests {
|
||||
TextBody::from_text(t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_and_sentence_final_sigma() {
|
||||
assert_eq!(change_case("ΟΔΟΣ", "title"), "Οδος");
|
||||
assert_eq!(change_case("ΟΔΟΣ ΜΟΥ.", "sentence"), "Οδος μου.");
|
||||
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_sigma_across_runs_is_not_final() {
|
||||
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
||||
assert_eq!(change_case_parts(&["ΟΔΟ", "ΣΑ"], "title"), ["Οδο", "σα"]);
|
||||
assert_eq!(change_case_parts(&["ΟΔΟΣ", " ΜΟΥ"], "title"), ["Οδος", " Μου"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_split() {
|
||||
let mut b = body("Hello");
|
||||
@@ -544,6 +607,74 @@ mod tests {
|
||||
assert_eq!(caret, (0, 5));
|
||||
}
|
||||
|
||||
/// The paste as it ran before the fast path: one `insert` (and normalize) per source run.
|
||||
fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
let mut caret = clamp(body, pos);
|
||||
for (k, sp) in src.paragraphs.iter().enumerate() {
|
||||
if k > 0 {
|
||||
caret = insert(body, caret, "\n", None);
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
p.level = sp.level;
|
||||
p.props = sp.props.clone();
|
||||
}
|
||||
}
|
||||
for r in &sp.runs {
|
||||
match r.kind {
|
||||
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
||||
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
||||
_ => {
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
let idx = split_at(p, caret.1);
|
||||
p.runs.insert(idx, r.clone());
|
||||
caret.1 += r.char_len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
caret
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_matches_inserting_run_by_run() {
|
||||
let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() });
|
||||
let mut src = body("");
|
||||
src.paragraphs = vec![
|
||||
Paragraph {
|
||||
runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")],
|
||||
..Default::default()
|
||||
},
|
||||
Paragraph {
|
||||
runs: vec![
|
||||
bold("h"),
|
||||
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
|
||||
Run::with("i", RunProps::default()),
|
||||
],
|
||||
level: 1,
|
||||
..Default::default()
|
||||
},
|
||||
Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() },
|
||||
];
|
||||
for at in [0, 1, 3, 6] {
|
||||
let mut dest = body("XYZabc");
|
||||
format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true));
|
||||
let mut want = dest.clone();
|
||||
let want_caret = insert_body_by_runs(&mut want, (0, at), &src);
|
||||
let caret = insert_body(&mut dest, (0, at), &src);
|
||||
assert_eq!(caret, want_caret, "caret at {at}");
|
||||
assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_insert_splits_into_paragraphs() {
|
||||
let mut b = body("abcd");
|
||||
let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None);
|
||||
assert_eq!(c, (3, 1));
|
||||
let texts: Vec<String> = b.paragraphs.iter().map(Paragraph::text).collect();
|
||||
assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_and_moves() {
|
||||
let b = body("Hello brave world");
|
||||
@@ -723,6 +854,22 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_crlf_matches_from_text() {
|
||||
// The kept suffix must not start inside a `\r\n` pair.
|
||||
let mut b = TextBody::from_text("x\ny");
|
||||
replace_text(&mut b, "z\r\ny");
|
||||
assert_eq!(b.text(), "z\ny");
|
||||
// CRLF in the kept prefix, and the same text with CRLF changes nothing.
|
||||
let before = notes();
|
||||
let mut b = before.clone();
|
||||
replace_text(&mut b, "Overview\r\nFirst point\r\nSecond point");
|
||||
assert_eq!(b, before);
|
||||
replace_text(&mut b, "Overview\r\nFirst point\r\nSecond\rpoint");
|
||||
assert_eq!(b.text(), "Overview\nFirst point\nSecond\npoint");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_keeps_formatting() {
|
||||
let before = notes();
|
||||
|
||||
@@ -1112,11 +1112,13 @@ impl Presentation {
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Indexed once: a per-slide scan of every layout is slides × layouts work.
|
||||
let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
for s in &self.slides {
|
||||
if !seen.insert(s.id) {
|
||||
out.push(format!("duplicate slide id {}", s.id));
|
||||
}
|
||||
if self.layout(s.layout).is_none() {
|
||||
if !layouts.contains(&s.layout) {
|
||||
out.push(format!("slide {} uses missing layout {}", s.id, s.layout));
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user